From 368c480bc2eb40433a484aeeb043eaf9499e1fc8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 09:44:49 +0200 Subject: [PATCH] feat(render) Campaign FW1: flood decomp appendix + the visibility math port The second decomp round (flood bookkeeping/propagation, view-clip support, landscape visibility) is archived Ghidra-arbitrated - it caught a load-bearing BN inversion (InsCellTodoList pops NEAREST-first, which is what makes the draw list far-to-near) and three more traps (the 192m elided constant, the min/max double positional swap, the copy_view cross order - the walk doc section 6 is corrected). WalkVisibilityMath ports get_pt_limit @0x0054b840, get_clip_height @0x0054cff0, corner/block_plane_check @0x0054b930/@0x0054d060, block_check @0x0054dc50, and viewconeCheck @0x0054c250 with retail boundary semantics (strict cull, inclusive partial, touch-out=Outside / touch-in=EntirelyInside) under 23 focused tests. Co-Authored-By: Claude Fable 5 --- ...2026-08-30-fw-flood-pseudocode-appendix.md | 703 ++++++++++++++++++ .../research/2026-08-30-fw-walk-pseudocode.md | 8 +- .../Rendering/Walk/WalkVisibilityMath.cs | 211 ++++++ .../Rendering/Walk/WalkVisibilityMathTests.cs | 218 ++++++ 4 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 docs/research/2026-08-30-fw-flood-pseudocode-appendix.md create mode 100644 src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs diff --git a/docs/research/2026-08-30-fw-flood-pseudocode-appendix.md b/docs/research/2026-08-30-fw-flood-pseudocode-appendix.md new file mode 100644 index 00000000..a6e65879 --- /dev/null +++ b/docs/research/2026-08-30-fw-flood-pseudocode-appendix.md @@ -0,0 +1,703 @@ +# FW1 flood decomp-read appendix - raw extraction reports (2026-08-30) + +Second read round: the interior-flood and view-support functions. Same method as the first appendix (BN pseudo-C, Ghidra-arbitrated where flagged). + +## Report 1 - Flood bookkeeping (InitCell / InsCellTodoList / GetVisible / curr_view_push) + +### PView::InitCell @0x005a4b70 + +**Summary:** Initializes the cell's TOP portal_view slot (portal_view.data[num_view-1]) for the flood: stamps view_timestamp = master_timestamp, clears cell_view_done, grows the per-portal portal_info array to num_portals, classifies every portal as in-view (inflag) or rejected via a cell-local viewpoint-vs-portal-plane side test, computes max_indist = max SQUARED viewpoint distance to any in-view portal vertex, and marks every rejected portal seen=1 so the flood never traverses it. The entry portal (real index; 0xffff sentinel never matches) is forced inflag=1 + seen=1 instead of side-tested. + +```c +int PView::InitCell(CEnvCell* cell, uint16 entry_portal_idx) // Ghidra: returns int; BN said void +{ + slot = cell->portal_view.data[cell->num_view - 1]; // TOP recursion slot + if (slot->view_count == 0) return 0; // no views on this cell -> nothing to do + + Render::positionPush(3, &cell->pos); // install CELL frame: viewpoint below is CELL-LOCAL + slot->cell_view_done = 0; + slot->view_timestamp = PView::master_timestamp; + if (slot->portal.sizeOf < cell->num_portals) + DArray::grow(&slot->portal, cell->num_portals); // exact-size; NEW entries UNINITIALIZED + + float max_d2 = 0.0f; + int any_rejected = /*UNINITIALIZED stack dword — see gotchas*/; + + for (i = 0; i < cell->num_portals; i++) { // CCellPortal stride 0x18 + CPolygon* poly = cell->portals[i].portal; + if (i == entry_portal_idx && slot->portal.data[i].inflag == 0) { + // entered-through portal: forced visible + consumed (0xffff seed sentinel never hits this) + slot->portal.data[i].inflag = 1; + slot->portal.data[i].seen = 1; + } else { + slot->portal.data[i].seen = 0; + // side of cell-local viewpoint vs portal plane (CPolygon.plane @ +0x20) + float d = plane.N.x*vp.x + plane.N.y*vp.y + plane.N.z*vp.z + plane.d; + int side; // 0=POSITIVE, 1=NEGATIVE + if (d > F_EPSILON) side = 0; // F_EPSILON = 0.000199999995f + else if (d < -F_EPSILON) side = 1; + else { slot->portal.data[i].inflag = 0; any_rejected = 1; goto vertex_scan; } // IN_PLANE: always reject + if (side != cell->portals[i].portal_side) + slot->portal.data[i].inflag = 1; // viewer on the see-through side + else { slot->portal.data[i].inflag = 0; any_rejected = 1; } // viewer on the portal's own side + } +vertex_scan: + if (slot->portal.data[i].inflag == 1 && poly->num_pts > 0) // num_pts = byte @ +0xe + for each CVertex* v in poly->vertices[0..num_pts) { // vertices = CVertex** @ +0 + d2 = (vp.x-v.x)^2 + (vp.y-v.y)^2 + (vp.z-v.z)^2; // SQUARED, cell-local, no sqrt + if (max_d2 < d2) max_d2 = d2; + } + } + slot->max_indist = max_d2; // max squared distance to any in-view portal vertex + + if (any_rejected != 0 && slot->view_count > 0) + for (v = 0; v < slot->view_count; v++) { + Render::set_view(&slot->view, v); // installs global active view; the check below does NOT read it + for (j = 0; j < cell->num_portals; j++) + if (portal[j].inflag == 0 && portal[j].seen == 0) + portal[j].seen = 1; // rejected portals become 'consumed': flood never walks them + } + + slot->update_count = slot->view_count; + Render::positionPop(); + return 1; +} +``` + +**Gotchas:** BN body @0x005a4b70 is UNUSABLE: it scrambled the x87 side-test control flow AND elided the squared-distance math (showed only the z subtraction). This model is Ghidra-verified (127.0.0.1:8081). Confirmed semantics: side==portal_side rejects, IN_PLANE (|d|<=0.000199999995f) always rejects — matches the FW doc's sidedness table. Real retail quirks: (1) any_rejected (local_4) is NEVER initialized — if no portal is rejected it reads stack garbage; the effect is benign (the fixup inner body no-ops when nothing was rejected; only side effect is redundant set_view churn), so a port should init it to 0 with identical observable behavior. (2) The entry-portal branch is guarded by the STALE inflag (inflag==0) — on a freshly grown portal array inflag is heap garbage (DArray::grow does NOT zero new entries); the caller (AddViewToPortals) presumably establishes it — verify that contract when porting FW3. (3) set_view inside the fixup loop installs each view globally but nothing in the loop consults it, and the LAST view stays installed on exit — both decompilers agree; purpose unclear (possibly vestigial). (4) positionPush(3, cell->pos) means the plane test and max_indist run in CELL-LOCAL coordinates. (5) 0xffff sentinel: ushort zero-extended vs uint loop index — never matches, so the seed cell side-tests every portal. (6) max_indist is a SQUARED distance — todo-list keys fed from it are squared; comparisons stay consistent. Ghidra return type is int (0 = early-out on view_count==0, 1 = did work); BN said void __stdcall. + +### PView::InsCellTodoList @0x005a4f50 + +**Summary:** Sorted insert into the flood todo list. Keeps cell_todo_list ordered NON-INCREASING by float distance from index 0: index 0 = FARTHEST, END = NEAREST. The new entry sinks toward index 0 past every entry with dist <= its own and stops under the first STRICTLY greater one. Since the flood pops from the END, pop order is SMALLEST-key-first (nearest-first traversal); ties pop FIFO (older equal entries first). 8-byte {cell,dist} nodes are lazily allocated once and recycled across floods; grow is batched +30 with explicit zero-fill of new slots. + +```c +void PView::InsCellTodoList(CEnvCell* cell, float dist) +{ + n = this->cell_todo_num; + if (n >= cell_todo_list.sizeOf) { + DArray::grow(&cell_todo_list, n + 0x1e); // +30; grow sets sizeOf to EXACTLY n+30 + for (k = this->cell_todo_num; k < cell_todo_list.sizeOf; k++) + cell_todo_list.data[k] = 0; // zero-fill ALL new slots (lazy-node contract) + } + if (cell_todo_list.data[n] == 0) + cell_todo_list.data[n] = new CellListType{cell=0, dist=0}; // 8 bytes; alloc-once, recycled, never freed here + node = cell_todo_list.data[n]; + node->cell = cell; node->dist = dist; + + pos = n; + while (pos > 0) { + prev = cell_todo_list.data[pos - 1]; + if (dist < prev->dist) break; // STRICT less stops the sink [GHIDRA polarity — BN was INVERTED] + cell_todo_list.data[pos] = prev; // shift the <=-dist entry toward the END + pos--; + } + cell_todo_list.data[pos] = node; + this->cell_todo_num++; +} +``` + +**Gotchas:** LOAD-BEARING BN INVERSION: BN's x87 flag mush (test ah,0x5) read as shift-while-LESS, which would produce an ascending list and a farthest-first pop. Ghidra (authoritative, verified live) shows `if (dist < prev->dist) break` — shift while dist >= prev->dist — descending list, END = nearest, pop-from-END = NEAREST-first flood. This reconciles exactly with DrawCells walking cell_draw_list from the end for far-to-near: cells append to the draw list in pop order (near->far), so end-first walk = far-to-near. Ties: equal-dist predecessors get shifted (>= shifts), so the newcomer lands closer to index 0 and pops AFTER existing equals — FIFO among ties (seed at dist 0 always pops first). No dedup: the same cell can be enqueued multiple times; dedup is the caller's job (ConstructView/AddViewToPortals). Nodes are pointer-recycled and permuted by the shift, never freed; the explicit zero-fill after grow is what makes the data[n]==0 lazy-alloc test sound (DArray::grow itself does NOT zero and sets sizeOf to exactly the requested count — the +30 batching is caller-side). + +### CEnvCell::GetVisible @0x0052dc10 + +**Summary:** Static resident-cell registry lookup: walks the bucket chain of CEnvCell::visible_cell_table (intrusive hash keyed by cell id, bucket = (uint64)cell_id % numBuckets) and returns the CEnvCell* whose node key matches, or null if the cell is not currently loaded/visible. Node layout {key @+0, next @+4, CEnvCell* value @+8}. + +```c +static CEnvCell* CEnvCell::GetVisible(uint32 cell_id) +{ + for (node = visible_cell_table.m_intrusiveTable.m_buckets[(uint64)cell_id % m_numBuckets]; + node != 0; node = node->next) // next @ +4 + if (node->key == cell_id) // key @ +0 + return node->value; // CEnvCell* @ +8 + return 0; +} +``` + +**Gotchas:** Trivial and unambiguous; both decompilers agree. BN's COMBINE(0, arg1) is just unsigned zero-extension of the 32-bit key for the modulo. The redundant post-match null re-check in both decomps (BN 'if (i != 0)' / Ghidra's second null test) is decompiler noise from the shared return path — one lookup, no side effects. + +### CEnvCell::curr_view_push @0x005a5090 + +**Summary:** Pushes one view-recursion level onto the cell: ensures portal_view has a slot at index num_view (exact-size grow to num_view+1, nulling just the ONE new slot), lazily allocates a 0x48-byte portal_view_type on first use (three empty DArrays with blocksize 0x80: portal_info list, view.poly, view.vertex; view_timestamp=0), then unconditionally resets EXACTLY three counters — view_count=0, update_count=0, view_timestamp=0 — and increments num_view. Slots are recycled across frames (never freed here); their DArrays keep capacity. + +```c +void CEnvCell::curr_view_push() +{ + if (num_view >= portal_view.sizeOf) { + DArray::grow(&portal_view, num_view + 1); // grow sets sizeOf EXACTLY num_view+1 + portal_view.data[num_view] = 0; // null only the single new slot + } + if (portal_view.data[num_view] == 0) { // lazy first-use alloc + s = operator new(0x48); + if (s) { + s->portal = {data=0, blocksize=0x80, next_available=0, sizeOf=0}; // DArray + s->view.poly = {data=0, blocksize=0x80, next_available=0, sizeOf=0}; + s->view.vertex = {data=0, blocksize=0x80, next_available=0, sizeOf=0}; + s->view_timestamp = 0; + // NOT initialized: view.vertex_count_total, max_indist, view_count, cell_view_done, update_count + } + portal_view.data[num_view] = s; + } + slot = portal_view.data[num_view]; + slot->view_count = 0; + slot->update_count = 0; + slot->view_timestamp = 0; + num_view += 1; +} +``` + +**Gotchas:** VERIFIES the earlier read: num_view++ with 0x48-byte lazy slot alloc + counter resets — with precision: exactly view_count/update_count/view_timestamp are reset on EVERY push (fresh or recycled); cell_view_done and max_indist are NOT reset here and stay stale (heap garbage on a brand-new slot) until PView::InitCell writes them — InitCell always runs before they are consumed, but a port must preserve that ordering or zero them harmlessly. The single-slot null after grow is sound only because this DArray grow(n) sets sizeOf to EXACTLY n (verified @0x005a45d0: copies old, sizeOf=arg; blocksize unused by grow; grow with arg<=sizeOf delegates to shrink) — no hidden capacity slack, so no garbage slots. portal_view_type layout confirmed in acclient.h: {DArray portal @0; view_type view @0x10 (vertex_count_total, poly@0x14, vertex@0x24); max_indist @0x34; view_count @0x38; cell_view_done @0x3c; view_timestamp @0x40; update_count @0x44}; DArray = {data, blocksize, next_available, sizeOf}. + +**Report notes:** All four bodies cross-checked against live Ghidra MCP (http://127.0.0.1:8081, patchmem.gpr) — mandatory here, because BN got two of them materially wrong: (1) InsCellTodoList's insertion comparison was polarity-INVERTED in BN (would have modeled a farthest-first pop); Ghidra's strict `dist < prev->dist` break gives a descending-from-index-0 list whose END-pop is NEAREST-first, which is what makes the draw list come out near->far and DrawCells' end-first walk far-to-near — consistent with FW doc section 5. (2) InitCell's BN body scrambled the plane-side branches and elided the dx^2+dy^2+dz^2 accumulation entirely (showed a bare z subtraction). Ghidra-confirmed model: side 0/1 vs portal_side rejects on equality, IN_PLANE always rejects (matches the doc's section 7 sidedness table), max_indist = max SQUARED cell-local distance to in-view portal vertices, and rejected portals get seen=1 in a fixup pass whose per-view set_view calls are side-effect-only. Two genuine retail quirks worth register-awareness if ported observably: InitCell's any_rejected flag is an uninitialized stack read (benign in effect), and the entry-portal force-visible branch keys off STALE inflag whose state is a caller contract (read PView::AddViewToPortals before relying on it in FW3). Struct authorities verified in acclient.h: portal_info {seen, inflag}; portal_view_type (0x48 bytes, field offsets in the curr_view_push gotchas); PView {outside_view, draw_landscape, outdoor_portal_list, cell_draw_list, cell_draw_num, cell_todo_list, cell_todo_num, lscape}; CellListType nodes are 8-byte {CEnvCell* cell, float dist}; CPolygon {vertices@0, num_pts byte@0xe, plane@0x20}. Sources: docs/research/named-retail/acclient_2013_pseudo_c.txt lines 311378-311393, 432896-433045, 433183-433243, 433279-433320; DArray grow/shrink @0x005a45d0 region; struct defs in docs/research/named-retail/acclient.h (portal_info @32458, portal_view_type @32346, view_type @32338, PView @45934, CPolygon @31855). + +## Report 2 - Flood propagation (ClipPortals / AddViewToPortals) + +### PView::ClipPortals @0x005a5520 + +**Summary:** Per popped flood cell: pass 1 scans the cell's top portal_view flags (seen && inflag!=1) and lazily resolves neighbor pointers via CEnvCell::GetVisible; returns 0 if no portal is live. Pass 2, in the cell's frame, installs each view in [start_view, view_count) and clips every live portal polygon (GetClip, do_clip=1). Survivors are appended to the neighbor's top portal_view (double-clipped via OtherPortalClip when exact_match==0 && other_portal_id>=0), or into this->outside_view for portals leading outdoors. Returns 1 when any portal was live. + +```c +int PView::ClipPortals(CEnvCell* cell, uint start_view /*first view index to process; 0 from ConstructView@005a5845, top->update_count from AdjustCellView@005a5796*/) +{ + portal_view_type* top = cell->portal_view.data[cell->num_view - 1]; // cell's current (top) view slot + Render::PortalList = top; // GLOBAL set unconditionally, before any early-out + int any_live = 0; + if ((int)cell->num_portals <= 0) return 0; // signed + + // ---- pass 1: which portals are live; resolve+cache neighbor pointers ---- + for (int j = 0; j < (int)cell->num_portals; j++) { // CCellPortal stride 0x18 + portal_info* pi = &top->portal.data[j]; // {seen@+0, inflag@+4} + if (pi->seen != 0 && pi->inflag != 1) { + CCellPortal* p = &cell->portals[j]; + if (p->other_cell_ptr == null && p->other_cell_id != 0xFFFFFFFF) { + p->other_cell_ptr = CEnvCell::GetVisible(p->other_cell_id); // cached into the portal record + if (p->other_cell_ptr == null) continue; // neighbor not visible/loaded => not live + } + any_live = 1; // live: ptr already set, OR id==0xFFFFFFFF (outdoors), OR GetVisible succeeded + } + } + if (!any_live) return 0; + + // ---- pass 2: clip every live portal against each view in the window ---- + Render::positionPush(3, &cell->pos); // enter the cell's frame + for (uint i = start_view; (int)i < (int)top->view_count; i++) { // signed compare + Render::set_view(&top->view, i); // install view i (CPU globals only) + for (int j = 0; j < (int)cell->num_portals; j++) { + portal_info* pi = &top->portal.data[j]; + if (pi->seen == 0 || pi->inflag == 1) continue; + CCellPortal* p = &cell->portals[j]; + uint n; + PView::GetClip(this, (Sidedness)p->portal_side, p->portal, clip_view /*global out buffer*/, &n, 1 /*do_clip*/); + if (n == 0) continue; // portal fully clipped away in this view + if (p->other_cell_id == 0xFFFFFFFF) { // ---- portal leads OUTDOORS ---- + if (this->draw_landscape != 0) { + if (cliplandscape != 0) + Render::copy_view(&this->outside_view, clip_view, n); // exit view = the clipped portal shape + else + Render::copy_view(&this->outside_view, null, 0); // null-src marker view (see gotchas) + } + } else if (p->other_cell_ptr != null) { // ---- portal into a resolved neighbor ---- + if (p->exact_match == 0 && (int)p->other_portal_id >= 0) { // far poly differs => double clip + if (PView::OtherPortalClip(this, p, clip_view, &n) == 0) { + Render::set_view(&top->view, i); // restore after far-frame excursion; nothing survived + continue; + } + Render::set_view(&top->view, i); // restore; clip_view/n now the doubly-clipped poly + } + CEnvCell* nb = p->other_cell_ptr; + if (nb->num_view != 0) // neighbor must have a pushed view slot (add_views/stab_list) + Render::copy_view(nb->portal_view.data[nb->num_view - 1], clip_view, n); // append to NEIGHBOR top view + } + // else: id != 0xFFFFFFFF and neighbor unresolved => clipped view silently dropped + } + } + Render::positionPop(); + return 1; // 1 = some portal was live, even if the view window [start_view, view_count) was empty +} +``` + +**Gotchas:** BN's body is type-mushed garbage here (`esi_2->vtable` stands for BOTH top->portal.data and the view_count loop bound; `m_timeStamp` = top->view @+0x10) — this model is Ghidra-arbitrated (127.0.0.1:8081, matches doc §9 practice). Render::PortalList is set BEFORE the early-outs, so the global still points at this cell's top view even on return 0. The cliplandscape==0 arm calls copy_view(&outside_view, null, 0) — doc §6 documents copy_view(dest,null,4)=full-viewport quad; whether count 0 vs 4 matters must be verified in Render::copy_view's body before porting that arm. Loop/window compares are SIGNED ints. Ghidra names the pop positionPop, BN framePop — same paired call. draw_landscape==0 discards exit-portal views entirely (outside_view never raised). + +### PView::OtherPortalClip @0x005a5400 + +**Summary:** The double-clip helper for non-exact_match portals (the task's ~0x005a5495 temp_view + copy_view site). Snapshots the near-clipped polygon into a lazily-initialized function-static temp_view (view 0), enters the FAR cell's frame, installs that snapshot as the active view, and re-clips against the far cell's own matching portal polygon (portals[other_portal_id]) with INVERTED sidedness (pass 1 iff far portal_side==0). Writes the result back through clip_view/count; returns count!=0. + +```c +int PView::OtherPortalClip(CCellPortal* p, Vec2Dscreen** clip_view, int* n) +{ + static portal_view_type temp_view; // guard-bit lazy init ($S225): all 3 DArrays {data=0,sizeOf=0,next_available=0,blocksize=0x80}, view_timestamp=0, atexit dtor + temp_view.view_count = 0; // reset EVERY call + if (Render::copy_view(&temp_view, clip_view, *n) == 0) // snapshot the near-clipped poly as temp view 0 + return 0; // rejected (<3 survivors per copy_view rules) + int opid = p->other_portal_id; + CCellPortal* far = &p->other_cell_ptr->portals[opid]; // far cell's OWN portal entry + Render::positionPush(3, &p->other_cell_ptr->pos); // enter the FAR cell's frame + Render::set_view(&temp_view.view, 0); // install the snapshot as the active view + PView::GetClip(this, (Sidedness)(far->portal_side == 0 ? 1 : 0), // INVERTED vs the far cell's declared side + far->portal, clip_view, n, 1 /*do_clip*/); // result written back through clip_view/n + Render::positionPop(); + return *n != 0; +} +``` + +**Gotchas:** The sidedness inversion is a literal `== 0` test, NOT a 0<->1 swap: a far portal_side of 2 (IN_PLANE) would pass POSITIVE(0) — in-data values should only be 0/1, but don't port it as XOR. temp_view is one shared static (single-threaded renderer; not reentrant). BN's `int80_t` return / `result` from copy_view is FPU-tracking mush — Ghidra confirms the return is exactly (*n != 0), with an early return 0 when the snapshot copy_view fails. Only called when exact_match==0 && other_portal_id>=0, so far indexing is safe. + +### PView::AddViewToPortals @0x005a52d0 + +**Summary:** Runs after ClipPortals(cell,...) returned 1: schedules neighbors that just received views. For each portal with a resolved neighbor, live source flags (seen && inflag!=1), and a nonempty neighbor top view: first touch (update_count==0) => InitCell + InsCellTodoList keyed by the neighbor top view's max_indist (+0x34); duplicate reach with NEW views (update_count!=view_count) => AddToCell, plus FixCellList re-place/re-clip when the neighbor was already marked cell_view_done, then update_count=view_count; no new views => skip entirely. Finally SetOtherSeen marks the neighbor's back-portal when other_portal_id>=0. + +```c +void PView::AddViewToPortals(CEnvCell* cell) +{ + for (uint j = 0; j < cell->num_portals; j++) { // unsigned compare; CCellPortal stride 0x18 + CCellPortal* p = &cell->portals[j]; + CEnvCell* nb = p->other_cell_ptr; // null for outdoor portals + unresolved neighbors + portal_info* pi = &cell->portal_view.data[cell->num_view - 1]->portal.data[j]; // SOURCE cell top-view flags (re-read each iteration) + if (nb == null || pi->inflag == 1 || pi->seen == 0 || nb->num_view == 0) + continue; + portal_view_type* nbtop = nb->portal_view.data[nb->num_view - 1]; // neighbor's top view slot + if (nbtop->view_count == 0) continue; // ClipPortals copied nothing in => nothing to schedule + + if (nbtop->update_count == 0) { + // ---- first touch this flood: schedule the neighbor ---- + if (PView::InitCell(this, nb, (uint16)p->other_portal_id) != 0) // ZERO-EXTENDED 16-bit read of the id + PView::InsCellTodoList(this, nb, nbtop->max_indist); // todo distance key = neighbor top view's max_indist (float @+0x34) + // update_count NOT set here (InitCell's business); fall through to SetOtherSeen + } else if (nbtop->update_count != nbtop->view_count) { + // ---- duplicate reach (2nd+ portal into nb) WITH new views since last processed ---- + PView::AddToCell(this, nb, (uint16)p->other_portal_id); + if (nbtop->cell_view_done != 0) // nb already popped+processed by ConstructView + PView::FixCellList(this, nb, cell); // = AdjustCellPlace(nb, cell) + AdjustCellView(nb); + // AdjustCellView@005a5770 re-runs ClipPortals(nb, nbtop->update_count) + // then AddViewToPortals(nb) => recursive incremental re-flood + nbtop->update_count = nbtop->view_count; // fresh re-read AFTER FixCellList returns (both arms) + } else { + continue; // update_count == view_count: nothing new; NO SetOtherSeen either (goto loop end) + } + + if ((int)p->other_portal_id >= 0) // FULL-WIDTH SIGNED test (sentinel -1 skips) + PView::SetOtherSeen(this, cell, j); + // SetOtherSeen@005a4e30 (verified): q = &nb->portals[other_portal_id]; + // if (q->other_cell_ptr == null) q->other_cell_ptr = cell; // backlink fill + // bp = &nbtop->portal.data[other_portal_id]; if (bp->inflag != 0) bp->seen = 1; // mark back-portal seen + } +} +``` + +**Gotchas:** TASK CORRECTION: outside_view is raised in ClipPortals, not here — outdoor portals keep other_cell_ptr null forever (GetVisible is never called for the 0xFFFFFFFF sentinel), so the nb==null gate skips them. The InitCell/AddToCell argument is a zero-extended 16-bit read of other_portal_id (both decompilers agree): id -1 => 0xFFFF, which IS ConstructView's no-through seed sentinel — coherent, but note InitCell/AddToCell DO run for such portals while SetOtherSeen (full signed int test) is skipped. In the InitCell branch, SetOtherSeen fires even when InitCell returned 0. update_count = view_count is a fresh read AFTER the FixCellList recursion returns — a portal cycle that adds views to nb during that recursion gets marked consumed (retail behavior; do not 'fix'). BN's loop-pointer prints (`arg2 = &arg2->m_pNext`, uint16 reads) are byte-offset-accumulator artifacts; Ghidra's `_padding_` expressions are equally mushed — the field decode above is offset-verified against acclient.h (other_cell_ptr +4, other_portal_id +0x10, stride 0x18; portal_view_type max_indist +0x34, view_count +0x38, cell_view_done +0x3C, update_count +0x44). + +**Report notes:** Extraction sources: docs/research/named-retail/acclient_2013_pseudo_c.txt lines 433446-433737 (BN), cross-arbitrated function-by-function against live Ghidra MCP (127.0.0.1:8081) — the BN body of ClipPortals is type-confused throughout and MUST NOT be used directly; Ghidra's decomp was clean and is the authority for all three bodies, with struct offsets independently verified against docs/research/named-retail/acclient.h (portal_view_type /* 3415 */, CCellPortal /* 3406 */, portal_info /* 3407 */, CEnvCell /* 3405 */, PView /* 4912 */ with outside_view at offset 0). Caller contract (completes doc §5): ConstructView@0x005a57b0 pops a todo cell, appends it to cell_draw_list (grow +30), sets top->cell_view_done=1, then ClipPortals(cell, 0) and — only on return 1 — AddViewToPortals(cell). The second ClipPortals call site is AdjustCellView@0x005a5770 (reached via FixCellList@0x005a5250 = AdjustCellPlace + AdjustCellView), which passes start_view = top->update_count, i.e. arg3 is the first-unprocessed-view index and the update_count/view_count pair is a consumed-views watermark: that is the whole duplicate-reach mechanism — a cell reached through a second portal only re-clips the view window [update_count, view_count). clip_view is a global Vec2Dscreen** scratch buffer shared by GetClip; Render::PortalList is a global that GetClip/DrawCells consume. The seen/inflag ORIGIN (who first sets them per view) is not in these bodies — it belongs to InitCell/AddToCell/GetClip extraction; here they are only read (gate: seen!=0 && inflag!=1) and seen is set by SetOtherSeen on the neighbor's back-portal, gated on that back-portal's inflag!=0. Open verify-before-port items: Render::copy_view(dest, null, 0) semantics in the cliplandscape==0 arm (doc §6 only documents count=4), and the far-side sidedness ==0 test if any data ever carries portal_side 2. + +## Report 3 - View-clip support (copy_view / polyClipFinish / xformStart) + +### Render::copy_view @0x0054dfc0 + +**Summary:** Appends ONE polygon view slot to a portal_view_type: perspective-divides homogeneous screen points in place, marks survivors by dropping ~1px duplicates and collinear points (three closing wrap checks included), rejects <3 survivors (returns 0, view_count untouched), caps at 31, stores pt list + closing duplicate, computes screen bounds, then per-edge WORLD planes from unprojected rays (live path: ScreenToViewTransform; N=cross(ray[k+1],ray[k]) normalized unless degenerate; d=-dot(N,viewer_world_space.viewpoint)). pts==null builds the full-viewport root quad. Returns 1 on success. + +```c +int copy_view(portal_view_type* dest, Vec2Dscreen** pts, uint npts) // Ghidra-verified; returns int (BN showed void) + vc = dest->view_count + vbase = (vc == 0) ? 0 : dest->view.vertex_count_total // FIRST view resets the vertex pool + if (vc >= view.poly.sizeOf) DArray::grow(&view.poly, vc + 0x10) + + if (pts == null) { // ---- full-viewport root quad path + n = 4; view.vertex_count_total = vbase + 5 + if (vbase+5 >= view.vertex.sizeOf) DArray::grow(&view.vertex, vbase + 0x15) + poly[vc] = { vertex_count: 4, vertex_index: vbase } + v = &view.vertex.data[vbase] // W/H = render_device->m_viewportWidth/Height (uint->float) + v[0].pt=(0,H); v[1].pt=(W,H); v[2].pt=(W,0); v[3].pt=(0,0); v[4].pt=(0,H) // closing dup + goto bounds + } + + // ---- survivor marking + keep[0]=1; n=1; last=0; stl=0 /*second-to-last kept corner*/; second=0 /*idx of 2nd kept (local_220)*/ + for (i = 0; i < npts; i++) { + p = pts[i] + if (p->w != 1.0) { p->x /= p->w; p->y /= p->w; p->w = 1.0 } // perspective divide IN PLACE (mutates caller's points) + if (i == 0) continue + distinct = |pts[i].x - pts[last].x| > 1.0 || |pts[i].y - pts[last].y| > 1.0 // strict >, vs LAST KEPT + keep[i] = distinct + if (!distinct) continue // ~1px duplicate dropped; last unchanged + if (this is only the 2nd kept point) { n++; second = i } + else { + pp = pts[stl]; prev = pts[last]; cur = pts[i] + span = max(|pp.x - cur.x|, |pp.y - cur.y|) // Chebyshev chord length + cross = (pp.x - prev.x)*(prev.y - cur.y) - (pp.y - prev.y)*(prev.x - cur.x) + if (|cross| >= span) { n++; stl = last } // prev is a genuine corner (~1px deviation test) + else { keep[last] = 0; if (second == last) second = i } // prev collinear: un-keep it; n unchanged (prev out, cur in) + } + last = i + } + // ---- closing wrap checks against pts[0] + first = pts[0] + distinct = |first.x - pts[last].x| > 1.0 || |first.y - pts[last].y| > 1.0 + keep[last] = distinct + if (!distinct) { n--; last = stl } // last duplicates first: drop it + else { + span = max(|pts[stl].x - first.x|, |pts[stl].y - first.y|) + cross = (pts[stl].x - pts[last].x)*(pts[last].y - first.y) - (pts[last].x - first.x)*(pts[stl].y - pts[last].y) + if (|cross| < span) { keep[last] = 0; n--; last = stl } // last collinear on stl->first + } + stl = last + if (second > 0) { // is point 0 itself collinear between stl and second? + span = max(|pts[stl].x - pts[second].x|, |pts[stl].y - pts[second].y|) + cross = (first.y - pts[second].y)*(pts[stl].x - first.x) - (first.x - pts[second].x)*(pts[stl].y - first.y) + if (|cross| < span) { n--; keep[0] = 0 } + } + + if (n < 3) return 0 // REJECT: nothing written, view_count NOT bumped + if (n > 0x1f) n = 0x1f // cap 31 + view.vertex_count_total = vbase + n + 1 + if (>= view.vertex.sizeOf) DArray::grow(&view.vertex, vbase + n + 0x11) + poly[vc] = { vertex_count: n, vertex_index: vbase } + v = &view.vertex.data[vbase]; j = 0 + for (i = 0; i < npts; i++) + if (keep[i]) { v[j].pt.x = fabs(pts[i].x); v[j].pt.y = fabs(pts[i].y); j++ } // REAL fabs (Ghidra-confirmed) + v[n].pt = v[0].pt // closing duplicate vertex (its plane slot never written) + +bounds: // over v[0..n-1] only + xmin=xmax=v[n-1].pt.x; ymin=ymax=v[n-1].pt.y + for (k = n-2; k >= 0; k--) { + if (v[k].x < xmin) xmin=v[k].x; else if (v[k].x > xmax) xmax=v[k].x + if (v[k].y < ymin) ymin=v[k].y; else if (v[k].y > ymax) ymax=v[k].y + } + poly[vc].{xmin,xmax,ymin,ymax} = ... + + // ---- per-edge world planes via unprojected rays (stack array ray[0..n], stride 0xc) + if (newmethod == 1) // newmethod statically = 1: this is the LIVE path + for (k = n-1; k >= 0; k--) PrimD3DRender::ScreenToViewTransform(&ray[k], v[k].pt.x, v[k].pt.y) // world ray dir @0x0059aa40 + else // DEAD legacy software fork + for (k = n-1; k >= 0; k--) { + u = v[k].pt.x * Render::xinvscale - Render::tx + w = v[k].pt.y * Render::yinvscale - Render::ty + ray[k] = Render::Xaxis*u + Render::Yaxis*Render::vdst - Render::Zaxis*w + } + ray[n] = ray[0] // closing duplicate ray + for (k = n-1; k >= 0; k--) { + N = cross(ray[k+1], ray[k]) // NOTE ORDER: next x current + if (|N.x| >= F_EPSILON || |N.y| >= F_EPSILON || |N.z| >= F_EPSILON) // F_EPSILON = 0.000199999995f + N *= 1.0f / sqrt(N.x^2 + N.y^2 + N.z^2) // else left tiny/unnormalized (degenerate edge, no reject) + v[k].plane.N = N // view_vertex = {Vec2D pt; Plane plane} stride 0x18 + v[k].plane.d = -dot(N, Render::viewer_world_space.viewpoint) // edge k = screen verts k -> k+1 + } + dest->view_count += 1 + return 1 +``` + +**Gotchas:** BN body is heavy x87-flag mush; every branch here is Ghidra-cross-checked (8081/patchmem). (1) MUTATES the caller's points (in-place perspective divide). (2) The vertex copy applies REAL fabs to x,y — harmless post-clip (coords in-viewport up to rounding) but real. (3) Cap-31 quirk: if >31 survive, the copy loop still writes ALL survivors, then the closing dup overwrites index 31 — corrupt polygon; unreachable from the <=32-vert callers but not guarded. (4) Plane N = cross(ray[k+1], ray[k]) — the fw-walk doc §6 summary says cross(ray_i, ray_i+1), i.e. NEGATED; decomp order is next-x-current. (5) The corner-keep boundary is |cross| >= span per Ghidra (consistent at all 3 sites); BN rendered strictness ambiguously. (6) newmethod is statically 1 with no other write found — ScreenToViewTransform is the live unproject; the Xaxis/vdst fork is dead. (7) Grow checks are '>= sizeOf' with +0x10 slack. (8) Returns int 0/1 (BN showed void); reject leaves dest completely untouched. + +### ACRender::polyClipFinish @0x006b6d00 + +**Summary:** Sutherland-Hodgman clip of a homogeneous screen polygon (array of Vec2Dscreen pointers) against the INSTALLED view: first the w >= cdstW plane (cdstW = 0.000199999995f), then each edge of Render::portal_vertex[0..portal_npnts-1] whose inmask bit is clear (all 3 retail callers pass inmask=0 — clip every edge). Each pass scans input in REVERSE; pointer lists ping-pong between static tempPtPBuf and pts_out; a final reverse-copy restores the ORIGINAL winding. Any stage dropping below 3 vertices returns without writing *npts_out (callers pre-zero it). + +```c +void polyClipFinish(Vec2Dscreen** pts_in, int npts_in, Vec2Dscreen** pts_out, int* npts_out, int inmask) + // Installed view: Render::portal_vertex (view_vertex[] {pt, plane}, stride 0x18), Render::portal_npnts. + // Statics: tempPtBuf = Vec2Dscreen pool for intersections (stride 0x10), tempPtPBuf @0x009053d0 = pointer list. + mask = inmask << (0x1e - portal_npnts) // aligns mask bit `npnts` at bit 30; per-edge <<1 exposes it at sign bit + parity = 0 // pass parity; dstsel = { [0]: pts_out, [1]: tempPtPBuf } + freept = tempPtBuf + cur_pts = pts_in; cur_n = npts_in + + // ---- pass 0: w-clip, only if some w < cdstW (scan last-to-first) + if (exists i: pts_in[i]->w < cdstW) { // cdstW = 0.000199999995f (set @0x007247d5) + parity = 1; out = base = tempPtPBuf + prev = pts_in[0]; sprev = prev->w - cdstW; inprev = (sprev >= 0) + for (i = npts_in-1; i >= 0; i--) { // REVERSE traversal; wrap pair (pts[0], pts[n-1]) first + cur = pts_in[i]; s = cur->w - cdstW; incur = (s >= 0) // INSIDE = w >= cdstW + if (inprev != incur) { + t = sprev / (sprev - s) // lands exactly on w = cdstW + *freept = prev + (cur - prev)*t // ALL FOUR components x,y,z,w lerped + *out++ = freept++ + } + if (incur) *out++ = cur + prev = cur; sprev = s; inprev = incur + } + cur_n = out - tempPtPBuf + if (cur_n < 3) return // *npts_out NOT written + cur_pts = tempPtPBuf + } + + // ---- edge passes, edges iterated LAST-to-FIRST as pairs (a,b): + // (v[0], v[npnts-1]), (v[npnts-1], v[npnts-2]), ..., (v[1], v[0]) + a = portal_vertex[0] + for (b = &portal_vertex[npnts-1]; b >= &portal_vertex[0]; a = b, b--) { + mask <<= 1 + if (mask < 0 /*sign bit set*/) continue // inmask bit SET = poly already inside this edge: skip + parity ^= 1; out = base = dstsel[parity] + ex = b->pt.x - a->pt.x; ey = b->pt.y - a->pt.y // screen-space edge direction + side(p) = (p->x - a->pt.x * p->w) * ey - (p->y - a->pt.y * p->w) * ex // homogeneous 2D cross; w>0 preserves sign + prev = cur_pts[0]; s0 = side(prev); sprev = s0; inprev = (s0 <= 0) // INSIDE = side <= 0 (Ghidra-verified) + for (i = cur_n-1; i >= 0; i--) { // REVERSE traversal again + cur = cur_pts[i] + s = (i != 0) ? side(cur) : s0 // final pair (prev=cur_pts[1], cur=cur_pts[0]) reuses point 0's side + incur = (s <= 0) + if (inprev != incur) { + t = sprev / (sprev - s) + *freept = prev + (cur - prev)*t // 4-component homogeneous lerp + *out++ = freept++ + } + if (incur) *out++ = cur + prev = cur; sprev = s; inprev = incur + } + cur_n = out - base + if (cur_n < 3) return // *npts_out NOT written + cur_pts = base // just-written buffer becomes next pass's input + } + *npts_out = cur_n + if (parity != 0) // final data sits in tempPtPBuf, not pts_out + for (p = out-1; p >= base; p--) *pts_out++ = *p // reverse-copy into pts_out + // NET ORDERING: each pass reverses order (reverse scan); (#passes + conditional copy) is always even, + // so pts_out ends with the ORIGINAL input winding. +``` + +**Gotchas:** Ghidra-verified against BN's flag mush. (1) The w-plane is w >= cdstW = 0.000199999995f, NOT w >= 0 — BN's 'vs 0f' is fsub-cdstW rendering. (2) Edge INSIDE = side <= 0 (verified sensible: interior of the root full-viewport quad (0,H),(W,H),(W,0),(0,0) yields negative sides); BN's literal rendering inverts this — trust Ghidra. (3) Early <3 returns NEVER write *npts_out — all 3 callers (GetClip @0x005a43b2/0x005a4414, DrawPortalPolyInternal @0x0059bdb0) pre-zero it; a port must preserve or document that contract. (4) inmask bit mapping: iteration k tests original bit npnts-k+1; bit npnts = wrap edge (v[0],v[npnts-1]) pair, bit 1 = (v[1],v[0]); bit 0 (the CY-plane bit of set_view's npnts+1-bit in-mask) is NEVER tested here — but all callers pass inmask=0 anyway, so every edge always clips and the skip path is dead in practice. (5) Zero-pass case (all bits set + no wclip via GetObjectMatrix()*WorldToView*ViewToClip, then viewport-scales WITHOUT perspective divide — x=(bw/2)(x_clip+w), y=(bh/2)(w-y_clip) (y flipped, origin top-left), z=raw clip z, w=raw clip w. toScreen==0: object->view; raw view-space x,y stored, z/w from the affine transform (w=1). The divide happens downstream (copy_view divides; polyClipFinish clips pre-divide homogeneously). + +```c +Vec2Dscreen* xformStart(Vector3* p_obj, int toScreen) + M_ov = D3DXMatrixMultiply(Render::GetObjectMatrix(), render_device->m_GState.WorldToViewMatrix) // object->view (row-vector conv.) + M_oc = D3DXMatrixMultiply(M_ov, render_device->m_GState.ViewToClipMatrix) // object->clip + M = toScreen ? M_oc : M_ov // BOTH products computed unconditionally, EVERY call + v4 = D3DXVec3Transform((p_obj,1), M) // homogeneous (x,y,z,w) + slot = &tmpScreenBuffer[pt_to_use] // static ring @0x00871360, stride 0x10 + pt_to_use += 1; if (pt_to_use == 100) pt_to_use = 0 + if (toScreen == 0) { slot->x = v4.x; slot->y = v4.y } // raw VIEW-space x,y + else { + slot->x = v4.x*Render::bw*0.5 + v4.w*Render::bw*0.5 // = (bw/2)*(x_clip + w): viewport-scaled, NO divide + slot->y = v4.w*Render::bh*0.5 - v4.y*Render::bh*0.5 // = (bh/2)*(w - y_clip): y-flip, 0 = top + } + slot->z = v4.z // raw clip z (or view z); never viewport-mapped here + slot->w = v4.w // raw clip w (view path: w = 1, affine) + return slot +``` + +**Gotchas:** (1) NO perspective divide — output is homogeneous 'screen * w' coordinates; dividing x,y by w yields pixels in [0,bw]x[0,bh]. copy_view performs the divide (its w != 1.0 check is exactly this contract); polyClipFinish consumes them undivided via the (p.x - a.x*p.w) homogeneous side test and clips w at cdstW. (2) Recomputes BOTH matrix concatenations per vertex, even the unused one — a per-vertex cost quirk, do not 'optimize into' different rounding without noting it. (3) 100-slot static ring: >100 outstanding returned pointers alias (callers hold <=32). (4) Uses Render::bw/bh for scaling while copy_view's root quad uses render_device->m_viewportWidth/Height — kept equal by the device, but two different globals. (5) BN's '0x63 == pre-increment' check is the same as Ghidra's '==100 post-increment' wrap. + +**Report notes:** All three bodies extracted from docs/research/named-retail/acclient_2013_pseudo_c.txt and cross-checked function-by-function against the live Ghidra MCP (http://127.0.0.1:8081, patchmem 2013 build) — the BN text for copy_view and polyClipFinish is severe x87-flag/stack mush and would have yielded at least two inverted branch polarities (the edge inside test and the corner-keep test) plus a wrong w-clip plane (0 instead of cdstW) if read literally. Key contract facts for FW3/FW4: (a) view_vertex = {Vec2D pt; Plane plane} stride 0x18 (acclient.h line 32483), view_poly = {vertex_count, vertex_index, xmin, xmax, ymin, ymax} stride 0x18, portal_view_type per acclient.h line 32346; copy_view appends into poly slot [view_count] and vertex pool [vertex_count_total], resetting the pool when view_count==0. (b) The full chain: xformStart(v,1) produces homogeneous viewport coords (no divide) -> PView::GetClip @0x005a4320 pre-zeroes *npts_out, reverses the pointer order for Sidedness != POSITIVE, sets Render::PolyCurrent=null/PolyCurrentMod=1.0/PolyCurrentPos=1, and calls polyClipFinish(screen, n, out, npts_out, 0) — inmask is ALWAYS 0 from all three call sites, so the edge-skip fast path is dead; do_clip==0 skips clipping entirely (copy/reverse only). (c) polyClipFinish output preserves input winding (reverse-scan passes + the conditional final reverse-copy always cancel). (d) copy_view then divides, dedups/collinear-prunes at ~1px (corner survives iff |2D cross| >= Chebyshev span), rejects <3, caps 31, and builds per-edge world planes N = normalize(cross(ray[k+1], ray[k])), d = -dot(N, Render::viewer_world_space.viewpoint) — note the cross ORDER is next-x-current, the fw-walk doc's §6 one-liner has it reversed (doc fix candidate, affects plane sign conventions for any consumer like viewconeCheck). (e) The live unproject is ScreenToViewTransform @0x0059aa40 (newmethod statically 1, initializer at data 0x0081efe8, no other writes found): ndc_u = ((2*sx/vpW)-1)/ViewToClip[0][0], ndc_v = ((2*sy/vpH)-1)*(-1/ViewToClip[1][1]), ray = u*row0 + v*row1 + row2 of inverse(WorldToView), with out.y taking matrix column 2 and out.z column 1 (D3D y-up -> AC z-up wiring); un-normalized direction. (f) Constants: cdstW = F_EPSILON = 0.000199999995f (0x3951B717); dedup threshold 1.0 px; vertex cap 0x1f; poly grow +0x10; vertex grow +0x10 slack. Ghidra decomp copies saved in scratchpad (copyview_ghidra.c, polyclip_ghidra.c, xformstart_ghidra.c) if the parent wants the raw text. + +## Report 4 - Landscape visibility (get_clip_height / block_check / set_default_view / update_viewpoint) + +### Render::get_pt_limit @0x0054b840 + +**Summary:** Defines the ViewIntervalType cell encoding. Classifies the vertical column at (x,y) against ONE clip plane, returning a signed scalar: outside_val (1001f) = column wholly outside; inside_val (0f) = wholly inside; positive h = inside only BELOW height h (plane normal points down, N.z < -eps); negative -h = inside only ABOVE height h (normal points up, N.z > eps). Vertical planes (|N.z| <= eps) collapse to all-in/all-out via Plane::which_side at z=0. + +```c +// F_EPSILON = 0.000199999995f; sky_height = 1000f; inside_val = 0f; outside_val = 1001f (static init $E106: 1000+1) +float get_pt_limit(float x, float y, Plane* p) { + if (p->N.z > F_EPSILON) { // inside half-space is z >= h + h = -((x*p->N.x + y*p->N.y + p->d) / p->N.z); + if (h >= sky_height) return outside_val; // need z >= 1000: nothing + if (h > 0) return -h; // inside above h + return inside_val; // h <= 0: whole column inside + } else if (p->N.z < -F_EPSILON) { // inside half-space is z <= h + h = -((x*p->N.x + y*p->N.y + p->d) / p->N.z); + if (h <= 0) return outside_val; // nothing above ground inside + if (h >= sky_height) return inside_val; // everything below sky inside + return h; // inside below h + } else { // vertical plane: no z dependence + if (Plane::which_side(p, Vector3(x, y, 0f), F_EPSILON) == NEGATIVE) + return outside_val; + return inside_val; // POSITIVE or ON_PLANE + } +} +``` + +**Gotchas:** BN body is pure x87-flag mush (unusable); this is the Ghidra decomp. Note the ROLE of eps: sidedness of N.z picks which half-space is 'inside' — inside is always the POSITIVE side of the plane (dot(N,p)+d >= 0), consistent with copy_view's plane winding and the vertical-plane which_side test. Vertical-plane test point is (x, y, 0) — z hardwired to 0. Boundary: h exactly 0 in the up-normal arm returns inside_val (encoding is continuous at 0). + +### Render::get_clip_height @0x0054cff0 + +**Summary:** Fills one ViewIntervalType (struct { float bound[32]; } — 0x80 bytes, acclient.h #4994) for the grid corner at world (x,y): bound[0] = column vs the CY near plane (Render::viewer_world_space.CY, installed by update_viewpoint); bound[1..portal_npnts] = column vs each edge plane of the ACTIVE view (installed by set_view: portal_vertex[i-1].plane). Called per landblock-grid corner by LScape::draw_check_blocks and per cell-grid corner by LScape::landcell_check. + +```c +void get_clip_height(float x, float y, ViewIntervalType* out) { + out->bound[0] = get_pt_limit(x, y, &Render::viewer_world_space.CY); + for (i = 1; i <= Render::portal_npnts; i++) // npnts = active view's edge count (4 for default view) + out->bound[i] = get_pt_limit(x, y, &Render::portal_vertex[i-1].plane); +} +``` + +**Gotchas:** BN's plane pointer '(edi_1 + portal_vertex) - 0x10' decodes as view_vertex stride 0x18 (Vec2Dscreen pt @0 + Plane @+8): iteration 1 hits vertex[0].plane. Loop bound is INCLUSIVE (i <= portal_npnts), so npnts+1 floats are written — matches set_view's portal_inmask = (1 << (npnts+1)) - 1 (edges + CY). Coordinates are viewer-block-relative 'world' space (the landscape's viewer-rebased frame), same space the CY/edge planes were built in. + +### Render::corner_plane_check @0x0054b930 + +**Summary:** Tests one corner's per-plane interval encoding against the block's z-range [min_z, max_z]. Returns BoundingType: OUTSIDE=0, PARTIALLY_INSIDE=1, ENTIRELY_INSIDE=2. Decodes the get_pt_limit scalar: sentinels first, then the above-h (negative) and below-h (positive) interval arms. + +```c +BoundingType corner_plane_check(float bound, float min_z, float max_z) { + if (bound == outside_val) return OUTSIDE; // 1001f + if (bound != inside_val) { // != 0f + if (bound <= 0f) { // inside is z >= h, h = -bound + h = -bound; + if (h > min_z) { // block bottom below clip height + if (max_z <= h) return OUTSIDE; // whole block below h + return PARTIALLY_INSIDE; + } // h <= min_z: block wholly above h + } else if (bound < max_z) { // inside is z <= h, h = bound; block top above h + if (bound <= min_z) return OUTSIDE; // whole block above h + return PARTIALLY_INSIDE; + } // h >= max_z: block wholly below h + } + return ENTIRELY_INSIDE; +} +``` + +**Gotchas:** BN body is x87-flag mush; polarity decoded from Ghidra's '(a < b) == (a == b)' idiom = a > b. Equality edges: touching the clip height on the OUT side counts OUTSIDE (max_z == h / min_z == h both reject); touching on the IN side counts ENTIRELY_INSIDE. Arg order min_z-then-max_z is established by the double swap through block_check (see its gotcha). + +### Render::block_plane_check @0x0054d060 + +**Summary:** Combines the four corner classifications of one plane for a block: OUTSIDE only when ALL four corners are OUTSIDE; ENTIRELY_INSIDE only when ALL four are ENTIRELY_INSIDE; anything mixed is PARTIALLY_INSIDE. The first corner's class picks which unanimity test runs. + +```c +BoundingType block_plane_check(float b1, float b2, float b3, float b4, float min_z, float max_z) { + c1 = corner_plane_check(b1, min_z, max_z); + c2 = corner_plane_check(b2, min_z, max_z); + c3 = corner_plane_check(b3, min_z, max_z); + c4 = corner_plane_check(b4, min_z, max_z); + if (c1 == OUTSIDE) { + if (c2 == OUTSIDE && c3 == OUTSIDE && c4 == OUTSIDE) return OUTSIDE; + } else if (c1 == ENTIRELY_INSIDE && c2 == ENTIRELY_INSIDE && c3 == ENTIRELY_INSIDE && c4 == ENTIRELY_INSIDE) { + return ENTIRELY_INSIDE; + } + return PARTIALLY_INSIDE; +} +``` + +**Gotchas:** This is conservative per plane: 3-of-4 corners outside one plane still yields PARTIAL (the block may straddle the plane). Cross-plane rejection happens in block_check, which ANDs OUTSIDE across planes — a block fully outside ANY single plane is culled. + +### Render::block_check @0x0054dc50 + +**Summary:** The exact block-vs-view interval test. Takes the block's four corner ViewIntervalTypes (west[y], west[y+1], east[y], east[y+1]) plus the block's max_zval/min_zval. Tests plane 0 (CY) first with early-out, then each of the active view's portal_npnts edge planes: any plane reporting all-corners-OUTSIDE culls the block; any PARTIAL demotes the running result; otherwise the plane-0 result (possibly ENTIRELY_INSIDE) survives. + +```c +// caller passes (..., blk->max_zval, blk->min_zval) — note arg5=max, arg6=min +BoundingType block_check(ViewIntervalType* i00, ViewIntervalType* i01, + ViewIntervalType* i10, ViewIntervalType* i11, + float max_zval, float min_zval) { + result = block_plane_check(i00->bound[0], i01->bound[0], i10->bound[0], i11->bound[0], + min_zval, max_zval); // CY plane first (args swapped back to min,max) + if (result == OUTSIDE) return OUTSIDE; + for (k = 1; k <= Render::portal_npnts; k++) { + r = block_plane_check(i00->bound[k], i01->bound[k], i10->bound[k], i11->bound[k], + min_zval, max_zval); + if (r == OUTSIDE) return OUTSIDE; // fully outside ONE plane => culled + if (r == PARTIALLY_INSIDE) result = PARTIALLY_INSIDE; // sticky demotion + } + return result; // ENTIRELY_INSIDE only if every plane said so +} +``` + +**Gotchas:** Argument-order trap: block_check receives (max_zval, min_zval) but forwards (arg6, arg5) = (min, max) to block_plane_check, which forwards positionally to corner_plane_check — net effect corner_plane_check(bound, min_z, max_z). Ghidra confirms the swap. BN's pointer-difference loop ('eax_5 = arg1 - arg3' etc.) is just base-relative addressing of bound[k] across the four structs — Ghidra's version shown here is the honest shape. Loop is 1..portal_npnts inclusive (bound[0] handled before the loop). + +### Render::update_viewpoint (Position const*) @0x0054cdd0 + +**Summary:** Installs the viewer for the frame: copies the pose into Render::viewer_pos, splits the rotation into Xaxis/Yaxis/Zaxis (right/forward/up rows of m_fl2gv), builds viewer_world_space = { viewpoint = eye origin, CY = near plane with N = Yaxis (forward), d = -dot(origin, Yaxis) - znear }, then pushes the D3D world-to-view matrix (view Z = forward, translation = world origin in viewer-local coords), refreshes lights, and recomputes selection_ray when check_selection is armed. This is ALL the state get_clip_height/viewconeCheck read besides set_view's. + +```c +void update_viewpoint(Position* pos) { + viewer_pos.objcell_id = pos->objcell_id; + viewer_pos.frame = pos->frame; + Xaxis = m_fl2gv[0..2]; Yaxis = m_fl2gv[3..5]; Zaxis = m_fl2gv[6..8]; // right, FORWARD, up + viewer_world_space.CY.d = -(origin.x*m[3] + origin.y*m[4] + origin.z*m[5]) - znear; + viewer_world_space.viewpoint = viewer_pos.frame.m_fOrigin; + viewer_world_space.CY.N = Yaxis; // near plane: dot(fwd,p) >= dot(fwd,eye)+znear + o = Frame::globaltolocal(&viewer_pos.frame, (0,0,0)); // world origin in view space + M = 4x4 { rows built from Xaxis / Zaxis / Yaxis, translation o, last row (0,0,0,1) }; // D3D: view Z = forward + RenderDeviceD3D::SetWorldToViewMatrix(render_device, &M); + m_pRenderer->UpdateLightsInternal(...); // vtbl +0x28 + if (check_selection) selection_ray = *pick_ray(&o, selection_x, selection_y); +} +``` + +**Gotchas:** The Frame overload @0x0054dc20 wraps into a stack Position{vftable, objcell_id = 0, frame} and tail-calls this — BN's 'var_48 = 0x796910' is Position's vftable pointer, NOT a cell id; objcell_id is 0. The exact element ordering of the D3D Matrix4 is ambiguous in both decompilers (Ghidra shows _21 = Xaxis.z, _31 = Xaxis.y — transposed-looking stack writes); semantics (world-to-view, forward mapped to view Z) are solid but element layout should be re-derived at port time if it becomes load-bearing. CY does NOT depend on the active view — it is rebuilt only here; set_default_view/set_view never touch it. + +### Render::set_default_view @0x0054ef50 + +**Summary:** Installs the outdoor full-screen view. One-time-inits the static Render::window (a portal_view_type; portal/poly/vertex DArrays zeroed, blocksize 0x80, atexit dtor), resets view_count and vertex_count_total to 0, appends the full-viewport quad via copy_view(&window, null, 0), sets Render::PortalList = &window, and activates it with set_view(&window.view, 0) — after which portal_npnts = 4, portal_inmask = 0x1F (4 edge planes + CY), portal_vertex points at the quad's verts, and xmin/xmax/ymin/ymax hold the full screen bounds. + +```c +void set_default_view() { + if (!($S273 & 1)) { // one-time static init + $S273 |= 1; + window.portal = window.view.poly = window.view.vertex = empty DArray (blocksize 0x80); + window.view_timestamp = 0; + atexit($E274); + } + window.view_count = 0; + window.view.vertex_count_total = 0; + copy_view(&window, /*screen pts*/ null, /*npts*/ 0); // null source => full-viewport quad: + // appends poly {vertex_count=4, vertex_index=base}, verts (0,H),(W,H),(W,0),(0,0) + wrap copy of v0 + // (W/H = render_device->m_viewportWidth/Height), computes xmin/xmax/ymin/ymax, + // builds 4 WORLD-space edge planes from the corner rays: + // ray = Xaxis*sx + Yaxis*vdst - Zaxis*sy (sx = x*xinvscale - tx, sy = y*yinvscale - ty) + // N = normalize(cross(ray_i, ray_i+1)), d = -dot(N, viewer_world_space.viewpoint) + // then window.view_count += 1 (=> 1) + Render::PortalList = &window; + set_view(&window.view, 0); // portal_npnts=4, portal_inmask=(1<<5)-1=0x1F, + // portal_vertex=&vertex[poly[0].vertex_index], screen bounds +} +``` + +**Gotchas:** Third copy_view arg here is 0 (source-point count, unused for the null source) — PView::DrawInside's root uses copy_view(top_view, null, 4); the null branch is identical, the count is ignored. copy_view has TWO ray paths gated on the global 'newmethod': ==1 uses PrimD3DRender::ScreenToViewTransform per point; else the inline xinvscale/yinvscale/tx/ty/vdst formula shown. Edge-plane normalization is skipped when every cross component < F_EPSILON (degenerate edge left unnormalized). The window's DArrays are grow-only and reused across frames; only the counters reset. copy_view returns 0 (view rejected, view_count NOT bumped) when <3 distinct screen points survive dedup — impossible for the null-source quad. + +### LScape::draw_check_blocks @0x00505f80 + +**Summary:** The per-frame landscape visibility pass (called by LScape::update_viewpoint @0x005062d0/0x0050634b). Clears every block's and land cell's in_view, (re)allocates the rolling corner-interval buffer, then FOR EACH VIEW in Render::PortalList (1 outdoor default view, or N portal exit views): installs the view with set_view, computes ViewIntervalType per landblock-grid corner at block_length (192 m) spacing in viewer-block-relative coords, and runs block_check per block; non-OUTSIDE blocks get in_view set and are refined per-cell by landcell_check @0x005050a0 (same machinery at 24 m). + +```c +void LScape::draw_check_blocks() { + // 1) clear + for (y,x over mid_width x mid_width) if (blk = land_blocks[mid_width*y + x]) { + blk->in_view = OUTSIDE; // +0xfc + for (i < side_cell_count^2) blk->lcell[i].in_view = 0; // CLandCell stride 0x108, field +0x104 + } + // 2) rolling corner buffer: 2 rows x (mid_width+1) ViewIntervalType (0x80 each) + if (block_interval && block_int_size != mid_width) delete[] block_interval, block_interval = null; + if (!block_interval) { block_interval = new[( mid_width+1) * 0x100]; block_int_size = mid_width; } + // 3) per active view + nviews = Render::PortalList ? Render::PortalList->view_count : 0; + v = 0; last = false; + do { + if (nviews == 0) last = true; + else { Render::set_view(&Render::PortalList->view, v); v++; if (v == nviews) last = true; } + // seed west column (grid x index 0) into row 0 + for (i = 0; i <= mid_width; i++) + get_clip_height((0 - viewer_b_xoff) * block_length, // block_length = 24*8 = 192f + (i - viewer_b_yoff) * block_length, + &block_interval[i]); + for (bx = 0; bx < mid_width; bx++) { + // east column (grid x = bx+1) into row (bx-1)&1 == (bx+1)&1 + for (by = 0; by <= mid_width; by++) + get_clip_height((bx + 1 - viewer_b_xoff) * block_length, + (by - viewer_b_yoff) * block_length, + &block_interval[((bx-1) & 1) * (mid_width+1) + by]); + for (by = 0; by < mid_width; by++) { + if (!(blk = land_blocks[mid_width*bx + by])) continue; + west = &block_interval[(bx & 1) * (mid_width+1) + by]; // x = bx corners + east = &block_interval[((bx-1) & 1) * (mid_width+1) + by]; // x = bx+1 corners + bt = Render::block_check(west, west+1, east, east+1, // +1 struct = y+1 corner (+0x80) + blk->max_zval, blk->min_zval); + if (bt != OUTSIDE) { blk->in_view = bt; LScape::landcell_check(this, blk); } + } + } + } while (!last); +} +``` + +**Gotchas:** BN ELIDED the grid-spacing constant to '0f' — the real multiplier is the global ::block_length = 192.0f (static init: 24*8; the 24f seen at 0x00505187/0x00505206 belongs to landcell_check's 24 m cell grid, a different function). in_view is only WRITTEN when the result is non-OUTSIDE, so across the multi-view loop a block visible in ANY exit view stays marked (values can also be overwritten ENTIRELY<->PARTIALLY by later views; landcell_check reruns per view). When PortalList is null the body still runs ONCE against whatever set_view state is stale — unreachable in practice (set_default_view always precedes the outdoor walk). Ghidra renders side_cell_count/lcell as '_padding_' — real names from CLandBlockStruct (acclient.h #3393): side_cell_count @+0x48, lcell @+0x90; the +0x104/0x108 constants mean CLandCell is 0x108 bytes with in_view last. + +**Report notes:** All addresses from docs/research/named-retail/acclient_2013_pseudo_c.txt; every x87-ambiguous body cross-checked against the live Ghidra MCP (patchmem, port 8081) — get_pt_limit, corner_plane_check, block_check, set_default_view, update_viewpoint, draw_check_blocks, and copy_view's null branch were all fetched and agree with BN's control flow where BN was readable. + +THE MODEL (how this completes FW sections 5-6): a ViewIntervalType is NOT a z-interval — it is a per-grid-corner VECTOR of 32 per-plane column classifications (bound[0] = the CY near plane from Render::viewer_world_space, bound[1..portal_npnts] = the active view's edge planes). get_pt_limit encodes each plane's intersection with the vertical column at (x,y) as a signed height: 0f = column wholly inside that plane's positive half-space, 1001f = wholly outside, +h = inside below h, -h = inside above h (sky_height = 1000f caps both). block_check then tests a landblock's z-slab [min_zval, max_zval] against the four corner columns, plane by plane: fully-outside-any-plane culls; per-plane mixing demotes to PARTIALLY_INSIDE; ENTIRELY_INSIDE requires unanimity on every plane. This is the landscape analog of viewconeCheck (which does the same CY+edge-planes test for a sphere) and consumes exactly the state installed by update_viewpoint (viewpoint, X/Y/Z axes, CY) + set_view (npnts, vertex planes, inmask). + +Outdoor default view: set_default_view appends ONE full-viewport 4-vertex quad view to the static Render::window (screen corners (0,H),(W,H),(W,0),(0,0), 4 world-space edge planes built from corner eye rays with d = -dot(N, eye)), points PortalList at it, and activates it — so the outdoor walk always runs draw_check_blocks with exactly one view of 4 edge planes + CY (portal_inmask 0x1F). Landscape-through-portals reuses the identical loop with PortalList = &outside_view and view_count = N clipped exit views (FW section 5's 'ov' arm). + +Constants for the port: F_EPSILON = 0.000199999995f; sky_height = 1000f; inside_val = 0f; outside_val = 1001f; block_length = 192f (24*8); ViewIntervalType = float[32] (0x80 bytes); BoundingType OUTSIDE=0 / PARTIALLY_INSIDE=1 / ENTIRELY_INSIDE=2; view_vertex stride 0x18 (Vec2Dscreen pt + Plane). + +Key traps recorded in per-function gotchas: (1) BN elided block_length to '0f' in draw_check_blocks — 24f in the neighborhood belongs to landcell_check, not this function; porting 24 here would shrink the visible landscape 8x. (2) block_check receives (max_zval, min_zval) but corner_plane_check consumes (min_z, max_z) via a double positional swap. (3) equality edges: touching a clip height on the out side is OUTSIDE, on the in side is ENTIRELY_INSIDE. (4) update_viewpoint(Frame) wraps with objcell_id = 0 (BN's 0x796910 is Position's vftable). (5) the D3D world-to-view Matrix4 element order is decompiler-ambiguous (flagged, not guessed) — semantics are right/up/forward rows with view Z = forward; not load-bearing for the CPU walk. + +Not extracted (out of scope, already covered or adjacent): copy_view's non-null source path and set_view are as FW section 6 describes (verified in passing); LScape::landcell_check @0x005050a0 is the per-cell (24 m) refinement using the same get_clip_height/interval machinery plus get_block_orient — it is the natural next extraction if FW needs cell-level in_view. + + diff --git a/docs/research/2026-08-30-fw-walk-pseudocode.md b/docs/research/2026-08-30-fw-walk-pseudocode.md index ea40b175..79ae6612 100644 --- a/docs/research/2026-08-30-fw-walk-pseudocode.md +++ b/docs/research/2026-08-30-fw-walk-pseudocode.md @@ -206,8 +206,12 @@ unreachable (every listed cell was `curr_view_push`ed). - `Render::copy_view` @0x0054dfc0 — appends ONE view: perspective-divided screen points (drop near-duplicates/collinear within ~1 px; <3 survivors ⇒ reject; cap 31), bounds, and per-edge WORLD-space planes - (`N = normalize(cross(ray_i, ray_i+1))`, `d = −dot(N, viewpoint)`). - `copy_view(dest, null, 4)` = the full-viewport root quad. + (`N = normalize(cross(ray[k+1], ray[k]))` — NEXT × CURRENT, corrected + 2026-08-30 by the flood-read appendix against Ghidra; an earlier + revision of this line had the operands reversed — + `d = −dot(N, viewpoint)`). `copy_view(dest, null, n)` = the + full-viewport root quad (count ignored). Full extraction: + `2026-08-30-fw-flood-pseudocode-appendix.md`. - `PView::GetClip` @0x005a4320 — projects the portal polygon (`xformStart`), reverses winding for NEGATIVE side, and (do_clip) clips via `ACRender::polyClipFinish` against the installed view. ≤32 diff --git a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs new file mode 100644 index 00000000..c447cbdf --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs @@ -0,0 +1,211 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW1 — retail's CPU visibility primitives, ported from the named +/// 2013 decomp (extractions: +/// docs/research/2026-08-30-fw-flood-pseudocode-appendix.md, report 4; all +/// x87-ambiguous branches Ghidra-arbitrated). Two families: +/// +/// 1. The landscape interval test — a grid corner's vertical column is +/// classified against each active clip plane as a signed height +/// (Render::get_pt_limit @0x0054b840), and a landblock's z-slab +/// is tested against its four corner columns plane by plane +/// (corner_plane_check @0x0054b930, block_plane_check +/// @0x0054d060, block_check @0x0054dc50). +/// 2. The sphere-vs-view test (Render::viewconeCheck @0x0054c250). +/// +/// Both consume the same installed state: the CY near plane (built by +/// Render::update_viewpoint) plus the active view's edge planes +/// (installed by Render::set_view). +/// +public static class WalkVisibilityMath +{ + /// Retail F_EPSILON (raw float 0x3951B717). + public const float Epsilon = 0.000199999995f; + + /// get_pt_limit sky cap: a clip height at or above this means + /// the column holds nothing (up-normal arm) / everything (down-normal + /// arm). + public const float SkyHeight = 1000f; + + /// Sentinel: the column is wholly inside the plane's positive + /// half-space. + public const float InsideColumn = 0f; + + /// Sentinel: the column is wholly outside (static init 1000+1). + public const float OutsideColumn = 1001f; + + /// + /// Render::get_pt_limit @0x0054b840: classify the vertical column + /// at (x, y) against one plane. Returns / + /// , a positive h (inside only BELOW h — + /// down-pointing normal), or a negative −h (inside only ABOVE h — + /// up-pointing normal). Vertical planes (|N.z| ≤ ε) collapse to + /// all-in/all-out via the plane side of (x, y, 0). + /// + public static float GetPointLimit(float x, float y, in WalkPlane plane) + { + Vector3 n = plane.Normal; + if (n.Z > Epsilon) + { + float h = -((x * n.X + y * n.Y + plane.D) / n.Z); + if (h >= SkyHeight) return OutsideColumn; + return h > 0f ? -h : InsideColumn; + } + if (n.Z < -Epsilon) + { + float h = -((x * n.X + y * n.Y + plane.D) / n.Z); + if (h <= 0f) return OutsideColumn; + return h >= SkyHeight ? InsideColumn : h; + } + // Vertical plane: no z dependence; test point (x, y, 0), retail + // Plane::which_side semantics (NEGATIVE = strictly below -epsilon). + float d = x * n.X + y * n.Y + plane.D; + return d < -Epsilon ? OutsideColumn : InsideColumn; + } + + /// + /// Render::get_clip_height @0x0054cff0: fill one corner's + /// interval vector — bounds[0] vs the CY plane, bounds[1..edgeCount] vs + /// each active edge plane (the loop is INCLUSIVE of edgeCount entries: + /// edgeCount + 1 floats are written). + /// + public static void FillClipHeights( + float x, float y, in WalkPlane cyPlane, ReadOnlySpan edgePlanes, + Span bounds) + { + bounds[0] = GetPointLimit(x, y, cyPlane); + for (int i = 0; i < edgePlanes.Length; i++) + bounds[i + 1] = GetPointLimit(x, y, edgePlanes[i]); + } + + /// + /// Render::corner_plane_check @0x0054b930: one corner's encoding + /// vs the block z-slab [minZ, maxZ]. Boundary semantics are retail's: + /// touching the clip height on the OUT side is Outside; on the IN side + /// is EntirelyInside. + /// + public static WalkBoundingType CornerPlaneCheck(float bound, float minZ, float maxZ) + { + if (bound == OutsideColumn) return WalkBoundingType.Outside; + if (bound != InsideColumn) + { + if (bound <= 0f) + { + // inside is z >= h, h = -bound + float h = -bound; + if (h > minZ) + { + if (maxZ <= h) return WalkBoundingType.Outside; + return WalkBoundingType.PartiallyInside; + } + } + else if (bound < maxZ) + { + // inside is z <= h, h = bound; the block top pokes above h + if (bound <= minZ) return WalkBoundingType.Outside; + return WalkBoundingType.PartiallyInside; + } + } + return WalkBoundingType.EntirelyInside; + } + + /// + /// Render::block_plane_check @0x0054d060: four corners vs one + /// plane. Outside/EntirelyInside require unanimity; anything mixed is + /// PartiallyInside. + /// + public static WalkBoundingType BlockPlaneCheck( + float b1, float b2, float b3, float b4, float minZ, float maxZ) + { + WalkBoundingType c1 = CornerPlaneCheck(b1, minZ, maxZ); + WalkBoundingType c2 = CornerPlaneCheck(b2, minZ, maxZ); + WalkBoundingType c3 = CornerPlaneCheck(b3, minZ, maxZ); + WalkBoundingType c4 = CornerPlaneCheck(b4, minZ, maxZ); + if (c1 == WalkBoundingType.Outside) + { + if (c2 == WalkBoundingType.Outside + && c3 == WalkBoundingType.Outside + && c4 == WalkBoundingType.Outside) + { + return WalkBoundingType.Outside; + } + } + else if (c1 == WalkBoundingType.EntirelyInside + && c2 == WalkBoundingType.EntirelyInside + && c3 == WalkBoundingType.EntirelyInside + && c4 == WalkBoundingType.EntirelyInside) + { + return WalkBoundingType.EntirelyInside; + } + return WalkBoundingType.PartiallyInside; + } + + /// + /// Render::block_check @0x0054dc50: the block-vs-view test over + /// four corner interval vectors. Retail's call site passes + /// (max_zval, min_zval) and an internal double positional swap hands + /// corner_plane_check (min, max) — this port takes (maxZ, minZ) to + /// mirror the caller's argument order and performs the same swap, so + /// call sites read like the decomp. Any plane with all four corners + /// outside culls; any partial plane demotes stickily; EntirelyInside + /// requires unanimity on every plane. planeCount = the active view's + /// edge count (bounds[0] is the CY plane, tested first with early-out). + /// + public static WalkBoundingType BlockCheck( + ReadOnlySpan corner00, ReadOnlySpan corner01, + ReadOnlySpan corner10, ReadOnlySpan corner11, + int planeCount, float maxZ, float minZ) + { + WalkBoundingType result = BlockPlaneCheck( + corner00[0], corner01[0], corner10[0], corner11[0], minZ, maxZ); + if (result == WalkBoundingType.Outside) return WalkBoundingType.Outside; + for (int k = 1; k <= planeCount; k++) + { + WalkBoundingType r = BlockPlaneCheck( + corner00[k], corner01[k], corner10[k], corner11[k], minZ, maxZ); + if (r == WalkBoundingType.Outside) return WalkBoundingType.Outside; + if (r == WalkBoundingType.PartiallyInside) + result = WalkBoundingType.PartiallyInside; + } + return result; + } + + /// + /// Render::viewconeCheck @0x0054c250 (plane-test half): a sphere + /// (center already in viewer-block space, radius already scaled) vs the + /// CY plane plus the active view's edge planes. Cull is STRICT + /// (d < −r); the partial flag is INCLUSIVE (d ≤ r) — a sphere exactly + /// tangent from inside counts Partial, not EntirelyInside. The retail + /// body's side effects (publishing local_object_center/radius) belong + /// to the walk context, not this math. + /// + public static WalkBoundingType ViewconeCheck( + Vector3 center, float radius, in WalkPlane cyPlane, + ReadOnlySpan edgePlanes) + { + float d = Vector3.Dot(cyPlane.Normal, center) + cyPlane.D; + if (d < -radius) return WalkBoundingType.Outside; + bool partial = d <= radius; + foreach (ref readonly WalkPlane plane in edgePlanes) + { + d = Vector3.Dot(plane.Normal, center) + plane.D; + if (d < -radius) return WalkBoundingType.Outside; + if (d <= radius) partial = true; + } + return partial ? WalkBoundingType.PartiallyInside : WalkBoundingType.EntirelyInside; + } +} + +/// Retail Plane: dot(N, p) + d, positive side = inside. +public readonly record struct WalkPlane(Vector3 Normal, float D); + +/// Retail BoundingType, values used raw by the walk. +public enum WalkBoundingType +{ + Outside = 0, + PartiallyInside = 1, + EntirelyInside = 2, +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs new file mode 100644 index 00000000..b1500aaa --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs @@ -0,0 +1,218 @@ +using System.Numerics; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +public sealed class WalkVisibilityMathTests +{ + // ---- get_pt_limit @0x0054b840 ---- + + [Fact] + public void Up_normal_plane_encodes_inside_above_as_negative_height() + { + // Plane z >= 5 at the origin column: N=(0,0,1), d=-5 → h=5, inside above. + var plane = new WalkPlane(new Vector3(0, 0, 1), -5f); + Assert.Equal(-5f, WalkVisibilityMath.GetPointLimit(0, 0, plane)); + } + + [Fact] + public void Up_normal_plane_at_or_above_sky_height_is_outside() + { + var plane = new WalkPlane(new Vector3(0, 0, 1), -1000f); + Assert.Equal( + WalkVisibilityMath.OutsideColumn, + WalkVisibilityMath.GetPointLimit(0, 0, plane)); + } + + [Fact] + public void Up_normal_plane_with_nonpositive_height_is_wholly_inside() + { + var plane = new WalkPlane(new Vector3(0, 0, 1), 3f); // z >= -3 + Assert.Equal( + WalkVisibilityMath.InsideColumn, + WalkVisibilityMath.GetPointLimit(0, 0, plane)); + } + + [Fact] + public void Down_normal_plane_encodes_inside_below_as_positive_height() + { + // Plane z <= 7: N=(0,0,-1), d=7 → h=7, inside below. + var plane = new WalkPlane(new Vector3(0, 0, -1), 7f); + Assert.Equal(7f, WalkVisibilityMath.GetPointLimit(0, 0, plane)); + } + + [Fact] + public void Down_normal_plane_with_nonpositive_height_is_outside() + { + var plane = new WalkPlane(new Vector3(0, 0, -1), -2f); // z <= -2: nothing above ground + Assert.Equal( + WalkVisibilityMath.OutsideColumn, + WalkVisibilityMath.GetPointLimit(0, 0, plane)); + } + + [Fact] + public void Vertical_plane_uses_the_side_of_the_ground_point() + { + var plane = new WalkPlane(new Vector3(1, 0, 0), -10f); // x >= 10 + Assert.Equal( + WalkVisibilityMath.OutsideColumn, + WalkVisibilityMath.GetPointLimit(5f, 0, plane)); + Assert.Equal( + WalkVisibilityMath.InsideColumn, + WalkVisibilityMath.GetPointLimit(15f, 0, plane)); + // ON the plane (within epsilon) counts inside. + Assert.Equal( + WalkVisibilityMath.InsideColumn, + WalkVisibilityMath.GetPointLimit(10f, 0, plane)); + } + + // ---- corner_plane_check @0x0054b930 ---- + + [Theory] + [InlineData(1001f, 0f, 10f, WalkBoundingType.Outside)] // sentinel outside + [InlineData(0f, 0f, 10f, WalkBoundingType.EntirelyInside)] // sentinel inside + [InlineData(-5f, 6f, 10f, WalkBoundingType.EntirelyInside)] // inside above 5; slab [6,10] wholly above + [InlineData(-5f, 2f, 10f, WalkBoundingType.PartiallyInside)] // slab straddles 5 + [InlineData(-5f, 2f, 4f, WalkBoundingType.Outside)] // slab wholly below 5 + [InlineData(7f, 2f, 6f, WalkBoundingType.EntirelyInside)] // inside below 7; slab wholly below + [InlineData(7f, 2f, 10f, WalkBoundingType.PartiallyInside)] // slab straddles 7 + [InlineData(7f, 8f, 10f, WalkBoundingType.Outside)] // slab wholly above 7 + public void Corner_check_classifies_the_slab( + float bound, float minZ, float maxZ, WalkBoundingType expected) + => Assert.Equal(expected, WalkVisibilityMath.CornerPlaneCheck(bound, minZ, maxZ)); + + [Fact] + public void Corner_check_boundary_touch_is_out_on_the_out_side_and_in_on_the_in_side() + { + // Retail equality edges (flood appendix report 4): maxZ == h with + // inside-above rejects; minZ == h with inside-above accepts entirely. + Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.CornerPlaneCheck(-5f, 2f, 5f)); + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.CornerPlaneCheck(-5f, 5f, 10f)); + // Inside-below: minZ == h rejects; maxZ == h accepts entirely. + Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.CornerPlaneCheck(5f, 5f, 10f)); + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.CornerPlaneCheck(5f, 2f, 5f)); + } + + // ---- block_plane_check @0x0054d060 ---- + + [Fact] + public void Plane_check_requires_unanimity_for_outside_and_entirely_inside() + { + Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.BlockPlaneCheck(1001f, 1001f, 1001f, 1001f, 0f, 10f)); + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.BlockPlaneCheck(0f, 0f, 0f, 0f, 0f, 10f)); + // 3-of-4 outside is still PARTIAL (the block may straddle the plane). + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.BlockPlaneCheck(1001f, 1001f, 1001f, 0f, 0f, 10f)); + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.BlockPlaneCheck(0f, 0f, 0f, 1001f, 0f, 10f)); + } + + // ---- block_check @0x0054dc50 ---- + + [Fact] + public void Block_check_culls_on_any_single_fully_outside_plane() + { + // Plane 0 (CY) inside everywhere; plane 1 outside at all four corners. + float[] c = [0f, 1001f]; + Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.BlockCheck(c, c, c, c, planeCount: 1, maxZ: 10f, minZ: 0f)); + } + + [Fact] + public void Block_check_demotion_is_sticky_across_planes() + { + // CY entirely inside; plane 1 partial at one corner; plane 2 entirely + // inside — the partial must survive to the final result. + float[] cornerA = [0f, -5f, 0f]; // straddles h=5 for slab [2,10] + float[] cornerB = [0f, 0f, 0f]; + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.BlockCheck( + cornerA, cornerB, cornerB, cornerB, planeCount: 2, maxZ: 10f, minZ: 2f)); + } + + [Fact] + public void Block_check_is_entirely_inside_only_with_unanimity_on_every_plane() + { + float[] c = [0f, 0f, 0f]; + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.BlockCheck(c, c, c, c, planeCount: 2, maxZ: 10f, minZ: 0f)); + } + + // ---- FillClipHeights (get_clip_height @0x0054cff0) ---- + + [Fact] + public void Clip_heights_write_the_cy_plane_then_every_edge_plane() + { + var cy = new WalkPlane(new Vector3(0, 0, 1), -5f); + WalkPlane[] edges = + [ + new(new Vector3(0, 0, -1), 20f), + new(new Vector3(1, 0, 0), -100f), + ]; + Span bounds = stackalloc float[3]; + + WalkVisibilityMath.FillClipHeights(0f, 0f, cy, edges, bounds); + + Assert.Equal(-5f, bounds[0]); + Assert.Equal(20f, bounds[1]); + Assert.Equal(WalkVisibilityMath.OutsideColumn, bounds[2]); + } + + // ---- viewconeCheck @0x0054c250 ---- + + private static readonly WalkPlane Cy = new(new Vector3(0, 1, 0), 0f); // forward = +Y, eye at origin + + [Fact] + public void Sphere_fully_behind_the_near_plane_is_outside() + => Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.ViewconeCheck(new Vector3(0, -5, 0), 1f, Cy, [])); + + [Fact] + public void Cull_is_strict_and_partial_is_inclusive_at_the_boundary() + { + // d == -r exactly: NOT culled (strict d < -r), and partial (d <= r). + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.ViewconeCheck(new Vector3(0, -1, 0), 1f, Cy, [])); + // d == +r exactly: tangent from inside counts PARTIAL, not entirely. + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.ViewconeCheck(new Vector3(0, 1, 0), 1f, Cy, [])); + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.ViewconeCheck(new Vector3(0, 1.01f, 0), 1f, Cy, [])); + } + + [Fact] + public void Edge_planes_cull_and_demote_like_the_cy_plane() + { + WalkPlane[] edges = [new(new Vector3(1, 0, 0), 0f)]; // inside is x >= 0 + Assert.Equal( + WalkBoundingType.Outside, + WalkVisibilityMath.ViewconeCheck(new Vector3(-3, 5, 0), 1f, Cy, edges)); + Assert.Equal( + WalkBoundingType.PartiallyInside, + WalkVisibilityMath.ViewconeCheck(new Vector3(0.5f, 5, 0), 1f, Cy, edges)); + Assert.Equal( + WalkBoundingType.EntirelyInside, + WalkVisibilityMath.ViewconeCheck(new Vector3(3, 5, 0), 1f, Cy, edges)); + } +}