feat(render): SAGE water/ocean shaders, per-user logs, camera & culling
Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl. Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
This commit is contained in:
+25
-13
@@ -277,13 +277,15 @@ export namespace ra3::client {
|
||||
} else if (event.type == ui_event_type::mouse_move) {
|
||||
mouse_x = event.x;
|
||||
mouse_y = event.y;
|
||||
if (event.left) {
|
||||
if (event.middle) {
|
||||
drag_x += event.dx;
|
||||
drag_y += event.dy;
|
||||
}
|
||||
} else if (event.type == ui_event_type::wheel) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
dirty = true;
|
||||
if (event.wheel != 0.0F) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running) break;
|
||||
@@ -345,8 +347,9 @@ export namespace ra3::client {
|
||||
/**
|
||||
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop
|
||||
* here owns the camera controls. The top-left shows the FPS (current /
|
||||
* cap) and, when `minimap_overview` is not empty, a corner minimap with
|
||||
* the camera location is drawn.
|
||||
* cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
|
||||
* `minimap_overview` is not empty, a corner minimap with the camera
|
||||
* location is drawn.
|
||||
*/
|
||||
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
|
||||
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
|
||||
@@ -372,6 +375,7 @@ export namespace ra3::client {
|
||||
bool presented = false;
|
||||
const auto default_camera = camera;
|
||||
bool middle_dragged = false;
|
||||
bool middle_down = false;
|
||||
while (running) {
|
||||
ui_event event;
|
||||
float drag_x = 0.0F;
|
||||
@@ -398,15 +402,23 @@ export namespace ra3::client {
|
||||
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
|
||||
}
|
||||
} else if (event.type == ui_event_type::wheel) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
camera_moved = true;
|
||||
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
|
||||
if (!middle_dragged) {
|
||||
camera.yaw = default_camera.yaw;
|
||||
camera.pitch = default_camera.pitch;
|
||||
camera.height = default_camera.height;
|
||||
if (event.wheel != 0.0F) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
camera_moved = true;
|
||||
}
|
||||
} else if (event.type == ui_event_type::mouse_button && event.middle) {
|
||||
if (event.released) {
|
||||
if (middle_down && !middle_dragged) {
|
||||
camera.yaw = default_camera.yaw;
|
||||
camera.pitch = default_camera.pitch;
|
||||
camera.height = default_camera.height;
|
||||
camera_moved = true;
|
||||
}
|
||||
middle_down = false;
|
||||
} else {
|
||||
middle_down = true;
|
||||
middle_dragged = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running) break;
|
||||
@@ -466,7 +478,7 @@ export namespace ra3::client {
|
||||
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
|
||||
fps_frames = 0;
|
||||
fps_window = now;
|
||||
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_);
|
||||
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_, this->name());
|
||||
overlay.label_changed = true;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -765,6 +765,7 @@ export namespace ra3::models {
|
||||
usize placed = 0;
|
||||
usize missing = 0;
|
||||
usize roads = 0;
|
||||
usize hidden = 0; ///< Objects/roads below the water plane, not drawn.
|
||||
|
||||
[[nodiscard]] auto empty() const -> bool { return indices.empty(); }
|
||||
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
|
||||
@@ -823,9 +824,14 @@ export namespace ra3::models {
|
||||
* @param ground_height Terrain height (world Z) at a world `(x, y)`, so
|
||||
* objects sit on the relief instead of a flat plane.
|
||||
* @param texture_size Edge length of the shared texture array (0 = auto).
|
||||
* @param cull_below_z Skip placements whose base sits below this world Z
|
||||
* (the water plane): an opaque water surface hides
|
||||
* submerged objects, so drawing them would float them
|
||||
* on top of the sea.
|
||||
*/
|
||||
[[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements,
|
||||
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U) -> scene {
|
||||
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U,
|
||||
float cull_below_z = -3.4e38F) -> scene {
|
||||
scene out;
|
||||
out.texture_size = texture_size == 0U ? 128U : texture_size;
|
||||
std::unordered_map<std::string, uint32> texture_layers;
|
||||
@@ -972,6 +978,12 @@ export namespace ra3::models {
|
||||
if ((a.road_type & 2U) == 0U) continue; // RoadType::Start
|
||||
const auto &b = placements[i + 1U];
|
||||
if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End
|
||||
const auto road_base = (ground_height ? ground_height(a.x, a.y) : 0.0F) + a.z;
|
||||
if (road_base < cull_below_z) {
|
||||
++out.hidden;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
emit_road(a, b);
|
||||
++i;
|
||||
}
|
||||
@@ -986,6 +998,10 @@ export namespace ra3::models {
|
||||
const auto cos_a = std::cos(item.angle);
|
||||
const auto sin_a = std::sin(item.angle);
|
||||
const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z;
|
||||
if (base_z < cull_below_z) {
|
||||
++out.hidden;
|
||||
continue;
|
||||
}
|
||||
bool drawn = false;
|
||||
for (const auto *mesh_asset: meshes) {
|
||||
// Only opaque material parts are drawn; meshes with no diffuse
|
||||
|
||||
@@ -314,10 +314,11 @@ export namespace ra3::render {
|
||||
}
|
||||
|
||||
/**
|
||||
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`.
|
||||
* `cap == 0` means vertical sync, `cap < 0` means uncapped.
|
||||
* A small translucent label for the top-left corner, e.g.
|
||||
* `FPS: 155/160 [vulkan]`. `cap == 0` means vertical sync, `cap < 0` means
|
||||
* uncapped. `backend` is the active renderer backend name (empty omits it).
|
||||
*/
|
||||
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image {
|
||||
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap, std::string_view backend = {}) -> image {
|
||||
char text[64];
|
||||
if (cap == 0) {
|
||||
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
|
||||
@@ -326,10 +327,16 @@ export namespace ra3::render {
|
||||
} else {
|
||||
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
|
||||
}
|
||||
const auto w = text_width(text, 1U) + 8U;
|
||||
std::string label{text};
|
||||
if (!backend.empty()) {
|
||||
label += " [";
|
||||
label += backend;
|
||||
label += ']';
|
||||
}
|
||||
const auto w = text_width(label, 1U) + 8U;
|
||||
const auto h = detail::glyph_height + 6U;
|
||||
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
|
||||
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U);
|
||||
draw_text(img, 4, 3, label, argb(240, 200, 90), 1U);
|
||||
return img;
|
||||
}
|
||||
|
||||
|
||||
+107
-42
@@ -259,6 +259,8 @@ export namespace ra3::terrain {
|
||||
/** The decoded terrain textures, parallel to `map_data::textures`. */
|
||||
struct texture_set {
|
||||
std::vector<image> images;
|
||||
image water_flow; ///< `ra3_deepocean.tga`: SAGE water flow/distortion (RG), optional.
|
||||
image water_normal; ///< `ra3_deepocean_nrm.tga`: SAGE water bump normal, optional.
|
||||
|
||||
[[nodiscard]] auto resolved() const -> usize {
|
||||
usize n = 0;
|
||||
@@ -294,6 +296,10 @@ export namespace ra3::terrain {
|
||||
};
|
||||
std::vector<fs::big_archive> archives;
|
||||
std::unordered_map<std::string, source> files;
|
||||
source flow_src{};
|
||||
source nrm_src{};
|
||||
bool has_flow = false;
|
||||
bool has_nrm = false;
|
||||
for (const auto &name: {"Terrain.big", "Core11.big"}) {
|
||||
const auto path = data_dir / name;
|
||||
std::error_code ec;
|
||||
@@ -302,9 +308,13 @@ export namespace ra3::terrain {
|
||||
}
|
||||
for (const auto &archive: archives) {
|
||||
for (const auto &entry: archive.entries()) {
|
||||
auto stem = detail::tga_stem(entry.name);
|
||||
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
if (!entry.name.ends_with(".tga") && !entry.name.ends_with(".TGA")) continue;
|
||||
auto stem = detail::tga_stem(entry.name);
|
||||
// The global ocean flow/normal pair is grabbed separately: the
|
||||
// tile index deliberately drops `_nrm` files.
|
||||
if (stem == "ra3_deepocean") { flow_src = source{&archive, entry.name}; has_flow = true; continue; }
|
||||
if (stem == "ra3_deepocean_nrm") { nrm_src = source{&archive, entry.name}; has_nrm = true; continue; }
|
||||
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
files.try_emplace(stem, source{&archive, entry.name});
|
||||
}
|
||||
}
|
||||
@@ -336,6 +346,16 @@ export namespace ra3::terrain {
|
||||
// Leave the slot empty; the renderer falls back to a palette.
|
||||
}
|
||||
}
|
||||
const auto decode_water = [](const source &src, bool present) -> image {
|
||||
if (!present) return {};
|
||||
try {
|
||||
return ra3::render::decode_tga(src.archive->read(src.entry, true));
|
||||
} catch (const std::exception &) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
set.water_flow = decode_water(flow_src, has_flow);
|
||||
set.water_normal = decode_water(nrm_src, has_nrm);
|
||||
return set;
|
||||
}
|
||||
|
||||
@@ -354,8 +374,8 @@ export namespace ra3::terrain {
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
|
||||
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
|
||||
/** Index `*.tga` under a terrain dir by stem (lower-cased). */
|
||||
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir, bool include_normals = false)
|
||||
-> std::unordered_map<std::string, std::filesystem::path> {
|
||||
std::unordered_map<std::string, std::filesystem::path> files;
|
||||
std::error_code ec;
|
||||
@@ -370,7 +390,7 @@ export namespace ra3::terrain {
|
||||
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
if (parent != "terrain") continue;
|
||||
auto stem = tga_stem(path.filename().string());
|
||||
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
if (!include_normals && stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
files.try_emplace(stem, path);
|
||||
}
|
||||
return files;
|
||||
@@ -396,11 +416,16 @@ export namespace ra3::terrain {
|
||||
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
|
||||
*/
|
||||
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
// Include normals so the global ocean flow/normal pair is staged too:
|
||||
// the terrain pass appends them to the atlas.
|
||||
const auto files = detail::terrain_file_index(dir, true);
|
||||
std::vector<std::filesystem::path> resolved;
|
||||
for (const auto &texture: map.textures) {
|
||||
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
|
||||
}
|
||||
for (const auto *water: {"ra3_deepocean", "ra3_deepocean_nrm"}) {
|
||||
if (const auto it = files.find(water); it != files.end()) resolved.push_back(it->second);
|
||||
}
|
||||
std::sort(resolved.begin(), resolved.end());
|
||||
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
|
||||
return resolved;
|
||||
@@ -409,21 +434,26 @@ export namespace ra3::terrain {
|
||||
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
|
||||
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
|
||||
const std::function<void(float)> &progress = {}) -> texture_set {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
const auto files = detail::terrain_file_index(dir, true);
|
||||
texture_set set;
|
||||
set.images.resize(map.textures.size());
|
||||
const auto decode_file = [](const std::filesystem::path &path) -> image {
|
||||
if (path.empty()) return {};
|
||||
try {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
return ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
for (usize i = 0; i < map.textures.size(); ++i) {
|
||||
const auto found = detail::match_terrain_file(files, map.textures[i].name);
|
||||
if (!found.empty()) {
|
||||
try {
|
||||
std::ifstream in(found, std::ios::binary);
|
||||
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
set.images[i] = ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
}
|
||||
set.images[i] = decode_file(found);
|
||||
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
|
||||
}
|
||||
if (const auto it = files.find("ra3_deepocean"); it != files.end()) set.water_flow = decode_file(it->second);
|
||||
if (const auto it = files.find("ra3_deepocean_nrm"); it != files.end()) set.water_normal = decode_file(it->second);
|
||||
return set;
|
||||
}
|
||||
|
||||
@@ -497,7 +527,12 @@ export namespace ra3::terrain {
|
||||
out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed);
|
||||
}
|
||||
|
||||
out.layer_count = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
|
||||
// Two extra atlas layers hold the SAGE water flow map and bump normal so
|
||||
// the water shader can sample them without a new binding on any backend:
|
||||
// they are always the last two layers (water_flow = layer_count - 2,
|
||||
// water_normal = layer_count - 1).
|
||||
const auto tile_layers = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
|
||||
out.layer_count = tile_layers + 2U;
|
||||
uint32 layer_size = 64U;
|
||||
for (const auto &img: set.images) {
|
||||
if (!img.empty()) layer_size = std::max(layer_size, img.width());
|
||||
@@ -512,17 +547,26 @@ export namespace ra3::terrain {
|
||||
}
|
||||
|
||||
out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U);
|
||||
for (usize i = 0; i < map.textures.size(); ++i) {
|
||||
const auto &img = set.images[i];
|
||||
if (img.empty()) continue;
|
||||
// Copy `img` into atlas layer `index`, box-nearest downscaled to
|
||||
// `layer_size`; `fallback` is the ARGB used when the image is absent.
|
||||
const auto blit_layer = [&](uint32 index, const image &img, uint32 fallback) {
|
||||
for (uint32 y = 0; y < layer_size; ++y) {
|
||||
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
|
||||
for (uint32 x = 0; x < layer_size; ++x) {
|
||||
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
|
||||
out.layers[(i * layer_size + y) * layer_size + x] = img.data()[static_cast<usize>(sy) * img.width() + sx];
|
||||
uint32 px = fallback;
|
||||
if (!img.empty()) {
|
||||
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
|
||||
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
|
||||
px = img.data()[static_cast<usize>(sy) * img.width() + sx];
|
||||
}
|
||||
out.layers[(static_cast<usize>(index) * layer_size + y) * layer_size + x] = px;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (usize i = 0; i < map.textures.size(); ++i) {
|
||||
blit_layer(static_cast<uint32>(i), set.images[i], 0xFF3A4550U);
|
||||
}
|
||||
blit_layer(tile_layers, set.water_flow, 0xFF808080U); // neutral flow (0, 0)
|
||||
blit_layer(tile_layers + 1U, set.water_normal, 0xFF8080FFU); // flat normal (0, 0, 1)
|
||||
if (progress) progress(1.0F);
|
||||
return out;
|
||||
}
|
||||
@@ -1096,49 +1140,70 @@ export namespace ra3::terrain {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto t = cell_size * 0.5F;
|
||||
auto dt = cell_size * 0.5F;
|
||||
// Clip the ray to the map's XY rectangle. The boundary is an
|
||||
// exact plane, so the silhouette there stays razor-sharp
|
||||
// instead of stair-stepping across it; outside the map is sky.
|
||||
auto t_enter = 0.0F;
|
||||
auto t_exit = 1.0e30F;
|
||||
const auto slab = [](float origin, float dir, float span, float &lo_t, float &hi_t) -> bool {
|
||||
if (std::abs(dir) < 1.0e-6F) return origin >= 0.0F && origin <= span;
|
||||
const auto a = (0.0F - origin) / dir;
|
||||
const auto b = (span - origin) / dir;
|
||||
lo_t = std::max(lo_t, std::min(a, b));
|
||||
hi_t = std::min(hi_t, std::max(a, b));
|
||||
return true;
|
||||
};
|
||||
if (!slab(cam_x, dx, world_w, t_enter, t_exit) || !slab(cam_y, dy, world_h, t_enter, t_exit) || t_exit <= 0.0F) {
|
||||
hi.data()[pixel] = argb(150, 170, 200);
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto surface_at = [&](float wx, float wy) -> float {
|
||||
const auto h = sample_height(wx, wy);
|
||||
return map.has_water ? std::max(h, static_cast<float>(map.water_plane_z)) : h;
|
||||
};
|
||||
|
||||
// March the heightfield cell by cell: the step is never longer
|
||||
// than the time to cross one cell (in the dominant horizontal
|
||||
// axis), while a clearance term lets the ray skip the empty air
|
||||
// above the surface. Resolving every cell is what keeps cliff and
|
||||
// map-edge silhouettes from quantising into huge stair-steps that
|
||||
// crawl/wave as the camera pans.
|
||||
const auto horiz = std::max(std::abs(dx), std::abs(dy));
|
||||
const auto cell_step = std::min(cell_size / std::max(horiz, 1.0e-4F), cell_size * 32.0F);
|
||||
auto t = std::max(t_enter, cell_size * 0.5F);
|
||||
auto prev_t = t;
|
||||
bool hit = false;
|
||||
float hit_t = 0.0F;
|
||||
for (int iter = 0; iter < 4000 && t < 60000.0F; ++iter) {
|
||||
for (int iter = 0; iter < 4096 && t <= t_exit; ++iter) {
|
||||
const auto wx = cam_x + dx * t;
|
||||
const auto wy = cam_y + dy * t;
|
||||
const auto wz = cam_z + dz * t;
|
||||
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) {
|
||||
prev_t = t;
|
||||
dt *= 1.03F;
|
||||
t += dt;
|
||||
continue;
|
||||
}
|
||||
if (map.has_water && wz <= map.water_plane_z) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
if (wz <= sample_height(wx, wy)) {
|
||||
const auto surface = surface_at(wx, wy);
|
||||
if (wz <= surface) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
const auto clearance = (wz - surface) / std::max(-dz, 1.0e-4F);
|
||||
prev_t = t;
|
||||
dt *= 1.03F;
|
||||
t += dt;
|
||||
t += std::clamp(clearance, cell_step, cell_step * 8.0F);
|
||||
}
|
||||
if (!hit) {
|
||||
hi.data()[pixel] = argb(150, 170, 200);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Refine the first crossing; with a sub-cell bracket this
|
||||
// converges to the exact surface point.
|
||||
auto lo = prev_t;
|
||||
auto up = hit_t;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
for (int i = 0; i < 18; ++i) {
|
||||
const auto mid = 0.5F * (lo + up);
|
||||
const auto wx = cam_x + dx * mid;
|
||||
const auto wy = cam_y + dy * mid;
|
||||
const auto wz = cam_z + dz * mid;
|
||||
const auto water = map.has_water && wz <= map.water_plane_z;
|
||||
if (water || wz <= sample_height(wx, wy)) {
|
||||
if (wz <= surface_at(wx, wy)) {
|
||||
up = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
|
||||
Reference in New Issue
Block a user