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 <noreply@anthropic.com>
703 lines
63 KiB
Markdown
703 lines
63 KiB
Markdown
# 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<portal_info>::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<CellListType*>::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<portal_view_type*>::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<portal_info>
|
|
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_info> 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<view_poly>::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<view_vertex>::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<view_vertex>::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 w<cdstW) would set *npts_out=npts_in with pts_out never filled — unreachable with inmask=0. (6) Static tempPtBuf/tempPtPBuf have NO bounds checks; callers assume <=32 verts (GetClip's reversal stack array is exactly 32 pointers). (7) Not reentrant (static buffers).
|
|
|
|
### PrimD3DRender::xformStart @0x0059b990
|
|
|
|
**Summary:** Transforms one object-space Vector3 into a slot of the 100-entry static Vec2Dscreen ring buffer and returns the slot. toScreen!=0: object->clip 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.
|
|
|
|
|