The IVT scan on the second probe catch settles EBX: F000:CA60 is the
emulator's default unhandled-interrupt callback -- the value filling every
vector nobody hooked (it matched ~56 of them), while the hardware-IRQ block
IVT[08-0F] holds the DPMI host's own 0D1F reflector stubs. The serial IRQs
are properly hooked; the host read one of the UNHOOKED vectors' default value
and probed [value + 0x3004] as a flat pointer, wrapping to the famous cr2.
Also corrected: the first dump read ESP/EIP as bare linear addresses -- the
'stack' bytes were DOS conventional memory (they decode as 16-bit real-mode
code), since SS=0107 has a nonzero base. SegPhys-corrected dumps are built
and armed for the next catch. The zeros at the faulting EIP are consistent
with DPMI32VM being a virtual-memory host that pages its own regions --
'Reference to a page you don't own' is its pager's abort message for an
address outside every region it owns.
Nailed: deterministic host-side path; the serial stream correlates as
exposure, not as trigger bytes; the bad read is [unhooked-vector-default +
0x3004]. Open: which vector, which host routine -- the corrected dumps
should name the return chain on the next catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VPX_PF_WATCH (fork, cpu/paging.cpp): on a guest page fault at the watched
linear address, dump guest registers, the last 64 serial RX deliveries with
guest cs:eip at each, the code bytes at the faulting EIP and the stack top.
serialnamedpipe's doReceive feeds the ring. Armed in podrun.sh and
launch_pod.ps1.
The first catch decoded the residual fault completely: CS:EIP 00FF:000066D4
in the DPMI host, EBX = F000CA60 -- an IVT entry read as a dword, segment
F000 offset CA60, a BIOS default interrupt handler -- and the faulting access
is [EBX+0x3004], whose 0x80000000 segment-base wrap gives exactly cr2
7000FA64. The host probes a word 0x3004 bytes past a real-mode vector value
treated as a flat pointer: harmless for its own low-memory handlers, a fault
for BIOS F000:xxxx defaults. The serial ring shows a steady 1-byte/1-3ms
vRIO stream with nothing special at the fault -- the stream determines which
vectors get walked, not the crash itself. The 0x3004 appears nowhere in
DPMI32VM.OVL or 32RTM.EXE as an immediate, so the probe now also dumps code
bytes at EIP; faulthunt.sh loops runs until the next catch.
Shipped baseline streak: 4/4 clean -- consistent with exposure, not yet
discriminating.
The inside view now loads the COCKPIT skeleton: the fleet-wide X-variant
naming convention (MAD->MAX etc., all 64 skeletons present) selects the same
25-joint chain with a single object -- max_cop.bgf, the MAX_COP canopy shell
with the PUNCH-texel windows from the capture forensics. The donor names the
same mechanism from the decomp side (inside = SkeletonType_A with '_cop'
selection). Fallback to the body skeleton when no X file exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TorsoSimulation now carries the donor's digital command handling (binary
@004b5cf0): elevate up/down, twist left/right with limit clamps, the
centre-button recenter latch slewing home across frames, and the ramp
machinery kept verbatim including the binary's punchline -- the shipped build
unconditionally overwrites the ramp with 1.0f, authored dead weight preserved
as the binary's shape. recenterActive carved from the dynamicsState reserve.
With 5.3.68's table fix the RIO/TM twist buttons land in these members and
move the torso, and the canopy eye rides the twist chain.
Also corrected in the roadmap: the 5.3.70 TriggerState 'wart' was not a wart
-- CheckFireEdge already compares fireImpulse's bit pattern as a signed int,
sign-correct for button ints and floats alike, and the donor binds and types
the member identically.
Fault rate, measured: riostreak.sh ran four consecutive vRIO-live runs --
CLEAN/CLEAN/FAULT/CLEAN, faults only ever in the load window (ticks 42-53k),
clean runs always past launch. Post-fix tally 8 runs / 2 faults (~25%; was
3/3 before the Torso fix). Our load window is ~60s where shipped's is ~15s:
if the residual is a constant-rate hazard during load, shipped's expected
per-run rate is only ~7% -- 'shipped survives' may be exposure luck, and the
residual is most likely an emulator-fork serial/DPMI interaction rather than
a game defect. Next probe: hook the fork's page-fault path on the fixed cr2
and dump recent serial-IRQ deliveries + guest cs:ip history, plus a
shipped-exe streak for the baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The canopy-eye confirming run (5th post-Torso-fix run with vRIO live) faulted
with the old signature during the load drain -- four clean runs then one
fault. The two-runs rule keeps earning its keep.
The BT_MAP_LOG audit now prints the whole resolved contract, not just NULLs.
The L4 list carries 13 direct mappings: mapper stick/throttle/reverse/looks
(ids 3/4/6/10-12), Torso torsoCenter (id 14, matching the donor), and
TriggerState (id 19 = the known 0x13 binding) on six weapons. Every
destination resolves and every size is right -- the 8-byte joystick write
lands in an 8-byte ControlsJoystick, buttons are ints into ints.
One type wart noted, not fixed: MechWeapon binds TriggerState onto
fireImpulse, a Scalar -- a ButtonGroup direct writes an int bit-pattern, so a
real trigger press stores 1.4e-45f. Size-safe, but the fire FSM will never
read a real button press as a pull; fire worked in tests only via the
BT_FORCE_FIRE hook, which writes a proper float. The RIO trigger needs an
int home for TriggerState.
Surviving fault theories, in current order: an emulator/DPMI-host serial-IRQ
interaction whose probability guest timing merely modulates (the fault's
constants -- fixed host EIP, fixed cr2, always during the load drain -- fit
this better than the corruption story ever did, since the smash point never
moved across builds where our BSS moved); a second corruption source outside
the direct mappings; sampling noise. riostreak.sh is measuring the post-fix
rate over four consecutive runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecurseSKLFile now walks "site=" children. Sites get no draw component --
which is why the walk's 26 nodes matched the real capture all along: sites
were never DCS nodes -- and siteeyepoint spawns the cockpit camera with the
donor's exact construction (bt411 btl4vid.cpp:462, decomp FUN_004579a8):
offset = the site's own local rest transform, parent = the site's PARENT
joint's DCS, not the hull root. World orientation, torso twist and gait all
arrive through chain composition. The root-DCS eye remains only as the
zero-construction fallback for skeletons with no siteeyepoint.
Measured on the live pod with the RIO streaming: [eye] cockpit eye on
'jointeye', 26 nodes / 19 objects / 1 eye, and the bridge camera sits at
Y = hull + 7.7 -- the exact chain sum jointlocal 5.29 + hip 0.37 + torso 0.31
+ jointeye 1.69. canopy_eye1.png is the pilot's view, the mech's own nose
wedge visible below the sightline. Own-mech cage handling is the next layer.
Also: eye_count threading through ReadSKLFile/RecurseSKLFile, and the [skl]
summary now reports it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The [map] audit named it in one run: subsystem 17 = Torso, attribute ids 12/13
unresolved. The donor's decompiled torso.hpp carries the authentic enum with
binary offsets -- ids 3..15, including StickPosition(9), TorsoUp/Down/
Left/Right(10-13), TorsoCenter(14), MotionState(15). Our table stopped at
seven entries with MotionState at id 9, on the strength of a comment claiming
the rest were messages.
The streamed control mappings bind BY ID as direct WRITE destinations, so the
truncation produced two different crashes from one cause: in TM mode the
button ids 12/13 resolved NULL (the boot write-fault the moment the joystick
polled), and in RIO mode the analog id 9 resolved to our motionState
StateIndicator -- every analog packet from a live vRIO wrote raw floats over a
watcher-socketed object, and the corrupted chains walked into unmapped memory
~30-40s later. That was the 'intermittent' pod fault at the fixed DPMI-host
address. vRIO down = no analog = no corruption, which is why the norio conf
launched: every observation from the whole hunt drops out of this mechanism.
Fix: the full 13-entry donor table; five int command members carved from the
dynamicsState reserve (class size unchanged); ctor zeros them.
Verified, two agreeing runs each way: TM mode launches with a clean audit and
the Thrustmaster joystick driving the torso (stickY=0.907 -> torsoElev 0.349);
RIO mode with vRIO STREAMING launches, zero faults, weapons cycling.
pod_render_rec is no longer poisoned and the norio workaround is obsolete.
The audit-before-the-archive-call pattern (BT_MAP_LOG walks the same streamed
table CreateStreamedMappings consumes, naming what will not resolve) turned a
two-day intermittent-corruption hunt into a one-run lookup. The streamed RES
tables are binding contracts on our attribute enums.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The operator caught it: the 5.3.65 image showed the mech hanging from its own
ground-shadow plate (-y is up in the bridge world). The follow-up pinned down
what was and wasn't wrong, and the reconstruction survives untouched.
Right, by three independent wire-level proofs: MAD.SKL offsets are a coherent
Y-up model (hip +5.29, knees descending, torso ascending -- anatomical only
read +Y-up); the BGF vertex extents agree (foot meshes extend -1.0 below their
ankle to the sole, torso +3.8 up to the missile pods); and the SHIPPED pod's
wire carries the same signs (hip +6.21, knees negative, vehicle root a pure
yaw with det +1). Our wire and shipped's are convention-identical -- no flip
exists between game and board in either game.
Wrong: the ad-hoc chase camera. It borrowed the aerial up-hint from the
calibrated live-bridge world (where -y is up) and applied it to a raw
scene-space render of Y-up wire data; the symmetric checkerboard ground
disguised the inversion. The shipped capture rendered upside-down through the
same harness, which is what cleared the wire.
Both conventions now documented in the roadmap: wire/scene space is Y-up in
both games; the live bridge presents -y-up via its calibrated mirror family
(FP_RIGHT_SIGN / CAGE_TWIST_SIGN, COCKPIT-CAGE-NOTES.md).
Tooling: pose_probe.py replaces the throwaway harness (updir=+1 scene space
default, -1 for the bridge sense). pose_chase_upfixed.png is the corrected
milestone image: the MadCat right side up -- chicken-walker legs, purple knee
actuators, feet planted, shadow under the feet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three clean eliminations on the live-RIO fault, all on the ack-fixed emulator:
not the ack heuristic (reproduces after the fix), not the debug logging
(pod_render_quiet faults identically with no BT_* env), and not the failed
test-mode handshake (the quiet run's init PASSED and it faulted anyway).
The Thrustmaster isolator -- L4CONTROLS=THRUSTMASTER so the game never opens
the RIO while vRIO keeps streaming into serial1 -- was blocked by its own
discovery: TM mode dies at boot in ControlsInstanceDirectOf<int>::Update+0x6,
mov eax,[eax+0x1c] with EAX=0, at guest 00485E1A. That address resolves
cleanly in our map and the loader names BTL4REC.EXE CODE 0x75E1A directly: a
real defect in our controls wiring for the non-RIO path, and its own
reconstruction brick.
Standing facts for the hunt: our exe + live vRIO = fault in ~30-40s; shipped
exe + the same stream on the same emulator = fine (two baselines). The ISR
and LBE4ControlsManager are archive code; ours in the per-event path are the
mapper (MECHMPPR) and the event/receiver plumbing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pose_chase.png: the MadCat assembled from its 19 parts, standing in the arena
at its live articulated position. Feet, legs, torso, weapon pods, canopy
plate. Not a collapsed stack at the origin, which is what every prior frame
actually showed.
The convention is no longer inferred -- it is read from the engine.
RootRenderable's ctor writes an entity pose into a DCS with
*(Matrix4x4*)dpl_GetDCSMatrix(myDCS) = localToWorld, so
Matrix4x4::operator=(const AffineMatrix&) IS the wire layout: row-major,
rotation in rows 0-2, zeros in column 3, translation in ROW 3 (entries
12/13/14).
The old 3/7/11 choice rested on two errors, both corrected in the code
comments: AffineMatrix does keep translation at 3/7/11, but because it is
COLUMN-major -- citing it as precedent for a row-major layout read the storage
and ignored the indexer (AFFNMTRX.HPP:99). And 'the last row made the maths
blow up' came from the contaminated bisect; those crashes were the RIO fault.
Rotation now composes through the engine's own Matrix4x4::operator=(const
EulerAngles&) from the page's pitch/yaw/roll, and the node write uses the
RootRenderable idiom (dpl_GetDCSMatrix + assign).
Also banked: the chase-frame fifobridge variant -- camera framed on the
articulated root instead of the arena bounds -- as the offline proof harness
for any future pose question.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
deadlock it exposed
The camera gap root-caused and fixed through three layers:
LAYER 1 (ours): "chain the engine after the skeleton" was a non-fix. The
engine's MakeEntityRenderables only accepts Object/Rubble resources, so
chaining it with a Skeleton printed "wrong video resource type" and built
NOTHING -- the fifodump proved it: zero vr_flush_dcs_artic records. The real
fix mirrors the engine's Mover composition (L4VIDEO.CPP:4795-4860) inside our
mech case: a Dynamic RootRenderable (whose ctor seeds its DCS from
localToWorld and whose Execute re-flushes on change -- the ONLY source of
per-frame wire articulation), the skeleton hung UNDER its DCS via ReadSKLFile's
new parent_dcs parameter, and for the inside view a DPLEyeRenderable on that
root with the published EyepointRotation. Also explains the mystery monolith
in the first frame: the skeleton was parked at the world origin with the
camera inside its shins.
LAYER 2 (1995 library, read from our own linked symbols): dpl_DrawSceneComplete
= velocirender_frameack(0), and frameack's unsolicited-reply path prints
"dpl error - unsolicited input during frame ack", adds the 1995 authors' own
puzzled "flush artic??" when the stray action is 0x1f, and calls exit(9).
Documented before it ever fires.
LAYER 3 (emulator, the actual deadlock): with per-frame articulation flowing
for the first time, the game stopped drawing at frame 232 -- one 0x1f burst
per frame forever, no receives, no error. The VPX device feeds the frame ack
only after 6 CONSECUTIVE empty polls and reset that counter on EVERY
outputData write; per-frame articulation writes made the count unreachable, so
dpl_DrawSceneComplete never went true. Fix: the reset is gated on
!frame_outstanding -- once a draw is outstanding the only receive the game
will do next is the frame ack. The iserver-drain concern the reset guarded is
boot-time only, when no frame is outstanding.
Verified: two agreeing runs of ours on the fixed emulator (611 draws / 222
artic batches in run 2 -- the 232 wall is gone), camera travelling with the
mech (cam -329.7,0,60.5 -> -362.3,0,54.5 across 8s), anim_abs 0 -> 1 on the
bridge, view now INSIDE the cockpit cage. Shipped binary re-run on the fixed
emulator: launches and runs, no regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MakeEntityRenderables no longer stops after the skeleton. It builds ours AND
chains DPLRenderer, which is what creates the RootRenderable and the
DPLEyeRenderable the camera follows. Verified over three runs:
[mer] 208 class=3001 view=1 res=1
[vid] type=1 file='mad.skl'
[skl] video\mad.skl -> 26 nodes, 19 objects
[chain] into DPLRenderer, skeleton=yes, stack at 0x12dde7
No fault, mission launches, sim runs. Worth noting separately: the engine's
eyepoint construction -- prime suspect for the page fault through most of a
session -- runs perfectly here, with the stack at 0x12dde7, right beside the
main loop's frame. Both are consistent with the corrected finding that the
fault is a live-RIO artifact at a fixed DPMI host address.
The camera still does not move: two frames six seconds apart differ by zero
pixels while [sim] shows the mech travelling from (487,20,288) to (493,20,301),
and the bridge reads cam (0.0, 10.0, 0.0) -- world origin plus eye height. So
the eye renderable was necessary but not sufficient.
Eliminated by reading our own code rather than guessing: the viewpoint entity
IS the mech (BTL4APP.CPP:354), the video renderer IS linked to it
(APP.CPP:1337), and SetupCull would Fail() loudly if the mech lacked
"siteeyepoint" or EyepointRotation -- neither Fail appears. That leaves the
path from SetupCull's worldToEyeMatrix to what the bridge reads off the wire,
which is bridge territory rather than reconstruction territory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every conclusion that named EulerAngles::operator=(const LinearMatrix&) as the
fault site was wrong, including the disassembly built on top of it. Three
independent proofs:
1. ROTATION.CPP is ours, so the function was instrumented directly -- print
this, &matrix and a local's address on the first twelve calls plus any wild
pointer. The run faulted with ZERO [euler] lines: never called.
2. After the probe was added, 0x66D9 disassembles to a two-byte conditional
jump (jnl) that touches no memory. The dump reports ErrCode 0004, a data
READ.
3. EIP 0x66D9 and cr2 0x7000FA64 are identical to the byte across every build
this session, including builds where the code at that offset changed
completely. A fault in our CODE segment moves when the code moves. This
one does not -- it lives in the DPMI host, which loads low and never gets
relinked.
The map lookup was a coincidence: our _TEXT is section-relative from 0, so its
offsets overlap the host's low addresses, and the map will name a function for
any small number. Before resolving a fault address against the map, confirm it
belongs to our segment -- cheapest check is whether it moves on relink.
What is actually true, by same-binary A/B:
vRIO DOWN, serial1 still a namedpipe -> mission LAUNCHES
vRIO UP, same conf, same binary -> FAULT
The trigger is a LIVE RIO stream, not an unplugged cable. The emulator reports
continuous serial1 RX overruns, the fault lands wherever the app happened to be
(terrain one run, the mech's inside view another), and the shipped binary
survives the identical conditions. A fault at a fixed host address, at
arbitrary points in the app, requiring an interrupt-driven stream, is an
interrupt re-entrancy signature rather than a wild pointer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two frames captured seconds apart are pixel-identical while [sim] shows the
mech travelling from (180.4, 10, -358.7) to (-604.8, 0, -352.4), with the
bridge title reading cam (0.0, 10.0, 0.0) throughout. The scene renders, the
eyepoint is never driven.
Our own MakeEntityRenderables explains it. When ReadSKLFile finds a Skeleton
entry it sets handled = True and the chain to DPLRenderer::MakeEntityRenderables
is skipped -- but that is the call which builds the RootRenderable and the
DPLEyeRenderable the camera follows (L4VIDEO.CPP:4548-4566). We hand the board
a skeleton with no root and no eye.
That also closes the loop on the fault: the eyepoint construction we skip is
the same code that faults when it runs. The mech case must end up doing BOTH,
so the EulerAngles::operator=(const LinearMatrix&) fault has to be understood
before the mech can be seen from its own cockpit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emulator/render-bridge/first_mission_frame.png -- arena city, textured
buildings, horizon, 28fps, captured from the GL bridge while our build ran a
live mission with the mech walking. The skeleton went in with it:
[skl] video\mad.skl -> 26 nodes, 19 objects.
Corrects yesterday's RIO framing. I called serial1 an unplugged cable. Wrong
twice: VRio.App is running on this rig (the dev-rig default since 7/17), and
the emulator log recorded real byte counts on that port -- RX overruns of 471,
503, 528 -- which an unconnected pipe cannot produce. The port was LIVE and
streaming during every crashing run.
So the defect is not that we mishandle a disconnected port, it is that we
mishandle a live RIO stream, which is worse: every production pod has a real
RIO. The shipped binary on the same rig logs 'lost RIO analog request', keeps
sending characters, and runs the mission; ours logs 'RIO never came back from
test mode!' and dies partway through the load. The overrun counts say the
guest is not draining the port fast enough. L4RIO.CPP's handshake is authentic
archive code, but MechRIOMapper (BT/MECHMPPR.CPP) is ours -- that is the thread.
pod_render_norio.conf stays the way to get a running mission today, but it is a
workaround: it disables the cockpit's primary input device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pod_render_norio.conf is pod_render_rec.conf with one line changed,
serial1=disabled instead of the vrio named pipe. Same binary, same egg. With
it, our build launches and the mech walks:
[launch] state=2 minPriorityEmpty=1 ticks=85316
[queues] p0=- p1=- p2=BUSY p3=- p4=-
BTL4Application::RunMissionMessageHandler
Turning Plasma Score Display On
[sim] pos=(180.466,10,-358.703) yaw=-0.275605 spd=14.4
[sim] pos=(251.959,10,-250.498) yaw=-0.89103 spd=14.4
No fault, 290 log lines and counting. This is the first time the
reconstruction has reached a running mission on the pod.
The hang was never starvation, a deadlock, or a refill loop -- it was VOLUME,
the first candidate I listed and then talked myself out of. 530 renderer
events queue at priority 0 during load; BackgroundTasks::Execute runs exactly
one task per call round-robin over seven tasks, and the 1ms frame budget is
always blown here so no extra background passes happen. That is ~9 events per
real second, so a load legitimately takes ~60s. The fault arrived at ~40s,
before the backlog could clear. Every 'the queue never drains' reading was
really 'the process dies before it can'.
The disproof was already in hand: the post tally climbed to 530 and went FLAT,
which means nothing was refilling. p0=BUSY with nextReady=1 is equally
consistent with a large finite backlog still draining, and I read it as refill.
Why the RIO port: the pipe has no server attached, which should behave as an
unplugged cable. The emulator log shows steady serial1 RX overruns, our build
prints 'RIO never came back from test mode!', and the SHIPPED binary prints
'lost RIO analog request' and launches anyway on this identical rig. So an
unplugged RIO is survivable and our handling of it is not -- a real defect, not
a rig artifact, since a pod with an unplugged RIO cable should still boot.
The wild reference at EulerAngles::operator=(const LinearMatrix&)+0x19 is still
unexplained, but it is now reproducible on demand by enabling serial1 rather
than being a coin flip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-priority occupancy, measured every second until the fault:
[queues] p0=BUSY p1=- p2=BUSY p3=- p4=- nextReady=1
That single line kills the two leading theories. p3/p4 empty means nothing
above priority 0 is competing, so the pump is free to serve it -- no
starvation. nextReady=1 means PeekAtNextEvent always has a READY event, so
priority 0 is not holding a timed event whose alarm never comes due -- no
deadlock. What remains is refill: priority 0 is replenished as fast as the
pump drains it, ~143 events per simulated second.
It is also fully deterministic. Two runs of the same binary reported
pump=352/1499 at frame 1001 and 495/2643 at frame 2002, identical to the byte.
So 'crashes about half the time' was never a race -- it was comparing runs that
differed in binary or conf.
Only InterestManager::PostRendererEvent posts at priority 0 (it maps every
renderer event there while the app is not RunningMission), so naming the
message names the flooder. Application::Post now tallies priority-0 posts by
message ID and reports the busiest three. Notably, all three producers of
NotifyOfNewInterestingEntity are entity-CREATION paths, so if that ID dominates
then something is creating entities in a loop -- which would explain the
unbounded growth behind the fault as well as the hang.
pod_render_noskl.conf now carries the same probes, so one instrumented binary
can be run with the skeleton walk on and off. Walk-off runs are the only
configuration of ours known to reach 'Turning Plasma Score Display On'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit's attribution was wrong and is corrected in the roadmap.
WRONG: 'the crash is the skeleton walk's' rested on 0 crashes in 2 runs with
the walk disabled -- a 25% coin flip presented as evidence. The oldest
preserved dump has no [skl] line and ends on the old 'couldn't figure out how
to MakeEntityRenderables' fallback, so it predates the walk and faults
identically.
WRONG: 'it lands at different points each time'. Every dump carries the same
numbers to the byte (cr2 7000FA64, EIP 66D9). What varies is how far the log
gets, not where the fault is.
Resolving the address through btl4opt.map names it exactly:
EulerAngles::operator=(const LinearMatrix&)+0x19, a read through the matrix
reference. A binary scan finds all three call sites of that operator pass a
stack local, so the 1.79GB pointer still has no static explanation -- that is
now its own open item rather than a guess.
Two theories killed cleanly by the new BT_STACK_LOG probe and a binary scan:
ESP drift (drift=0 over 2002 frames; exactly one callee-cleans function exists
in the whole binary) and an undersized stack (our PE and the shipped one have
identical 1MB/8K geometry).
What the probe found matters more: the application is parked in state=2, which
is LoadingMission, not WaitingForLaunch. Priority 0 is where the interest
manager queues renderer events during load, the gate needs that priority empty,
and it never empties -- so the mission never launches and the renderer holds a
blank screen by design. The fault arrives ~30s into that wait. Runs that DO
launch never print a single [launch] line. So 'crashes half the time' and
'hangs during load' are one event seen twice.
A 15x disagreement between two clocks looked like a reconstruction slip --
BTL4.CPP passes GetTicksPerSecond() where ApplicationManager wants a frame
rate. Checked against the shipped binary before touching it: same instruction
sequence, same kind of static float pushed. Authentic. Documented so nobody
'fixes' it.
Also swept every subsystem DefaultData against its real C++ base. Fifteen
chain past their immediate parent, but fourteen skip only classes that add no
handlers and no attributes, and no class's attribute-ID base disagrees with its
index chain -- so there are no gap slots there. The one real defect: Generator
is a HeatSink but chained to Subsystem::MessageHandlers, so it ignored every
ToggleCooling message. Fixed (compiles next build).
Tooling: podrun.sh stages the build over BTL4REC.EXE, exports the host-side VPX
board env -- without which the run dies at the iserver handshake rather than
merely rendering nothing -- and archives every run's log, marking it -CRASH
when it faulted. Before this the only preserved dump was an accident, on a rig
where each run costs four minutes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Added BT_NO_SKL, which skips the skeleton build so ONE executable can be run
with and without it. That is the only clean way to test an intermittent
fault, and it settles attribution:
walk enabled ~50% of runs die (page fault at 66D9) at DIFFERENT points
walk disabled 0 crashes in 2 runs
shipped exe 0 crashes in 2 runs, same rig and conf
So it is our new code, not the rig, and the varying fault point points at
memory corruption surfacing later rather than a bad instruction in the walk.
Hypotheses killed by reading the private library headers: the matrix array is
the right size (dpl_MATRIX is float32[4][4]); and neither s_dplobject nor the
common dpl_node header retains an object name, weakening the dangling-string
theory (a private name cache inside the library is still possible and is the
best surviving suspect).
Also checked something that would have been much worse than a crash:
~NotationFile REWRITES its file when dirty, and these are the game's shipped
.SKL data files. MAD.SKL is untouched, and ReadSKLFile now carries the
engine's own Verify(!IsDirty()) guard.
Next tests are listed cheapest-first in the roadmap: keep the NotationFile
alive, copy the Object= names, count board allocations, and re-check whether
MakeEntityRenderables runs twice per mech.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pod updates every few seconds. Measured by sampling a head window every
2s (emulator/render-bridge/headrate.py): OURS changed in 5 of 12 samples,
SHIPPED in 1 of 12. So the reconstruction is not slower than the shipped
binary -- the emulated board is just expensive. Caveat recorded with it:
ours was being driven by the throttle hooks while the shipped exe ignores
them and sat parked, so treat those as same-order, not a win.
What did cost us 3x was mine: BT_MECH_LOG/BT_LAUNCH_LOG write per-frame lines
and DEBUG_STREAM=cout is redirected to COM3, so every one goes through an
emulated serial port. Turning them off took the wire from 480 to ~1480
bytes/sec. pod_render_quiet.conf is the conf to use for timing work.
And a correction to my own earlier framing: wire bytes/sec is NOT a frame
rate. Shipped pushes ~8900 B/s against our ~1480 and the difference is
CONTENT, not speed -- shipped submits the mech and we do not:
OURS: L4VIDEO.cpp wrong video resource type for object mad.skl
SHIPPED: (no such line)
The mech's model resource is a SKELETON. The engine's default
MakeEntityRenderables accepts only Object/Rubble and rejects anything else
with exactly that message, because skeletons are the GAME renderer's job. So
our arena renders but the MECH IS ABSENT, and with it the cockpit interior --
one unimplemented path explaining both the missing model and the lower wire
volume.
Next brick is now precisely scoped: answer MechClassID by reading the .SKL
notation pages into a dpl_DCS hierarchy and hanging the per-node .BGF
geometry off it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emulator/render-bridge/first-3d-frame.png: the arena from the pod -- sky,
horizon, ground, the arena structures along the skyline -- at ~31fps on the
VelociRender bridge.
The whole chain now works in our build: MakeVideoRenderer ->
BTL4VideoRenderer over DPLRenderer -> board boot -> Renderer::LoadMission ->
DPLReadEnvironment for the art paths -> 40/40 arena objects loaded -> mission
launch -> per-frame submission over the wire.
The mistake worth remembering: the run was never stalled after
InitializePlayerLink. I called that from a 150-second sample and it was just
too short. CheckLoadMessageHandler reposts every second and will not advance
until the min-priority event queue drains, and with the video renderer in the
mission that takes minutes. A BT_LAUNCH_LOG trace showed the queue draining
and then both RunMissionMessageHandler calls, the plasma display, and the
first sensor tick -- matching the shipped binary line for line. The renderer
deliberately holds a blank screen until RunningMission (L4VIDEO.CPP:5100), so
'black' was the app still loading, not a rendering bug.
Zero unbuildable entities, zero geometry failures. The frame is static only
because the mech is parked with no input, and the camera in the bridge title
is the bridge's own viewer, not the game's eyepoint.
Also banked the launch-gate trace (BT_LAUNCH_LOG) behind an env var.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DPLRenderer::LoadMissionImplementation (L4VIDEO.CPP:6007) is not empty: it
calls DPLReadEnvironment (which opens L4DPLCFG/btdpl.ini and sets the dPL
object/material/texmap paths from the objectpath= entries) and then
LoadNameBitmaps. The 'bring-up no-op' installed earlier therefore did not do
nothing -- it REPLACED that, so the loader never learned where the art lives
and every dpl_LoadObject returned NULL.
I justified that no-op by pointing at VideoRenderer's bare Tell and
GaugeRenderer's identical one; neither is our base. Check the ACTUAL base
before overriding in this engine and chain it unless there is a reason not
to. DPLReadEnvironment is private to DPLRenderer, so chaining is the only
way a game renderer can reach it at all.
before: 40x 'couldn't load object', 'NULL instance', run ends,
wire ~24 bytes/sec
after: 0 failures, run continues, wire ~12 KB/s (1.17MB climbing),
bridge live at 88fps
Proved it was ours and not the rig by running the SHIPPED exe under the same
conf: it loaded every object. That A/B is cheap and is the right first move
whenever the pod misbehaves.
Still black: the bridge camera sits at its default (0,10,0), so the wire
carries state and object loads rather than a populated scene. The mech and
arena entities still need MakeEntityRenderables bodies.
Rig improvements, both from the user: nosound is fine on the SLOW clock (it
is the FAST SOS clock that needs the AWE32), which saves ~4 min and ~300MB of
audio taps per run; and serial3=file + '> COM3' gives a live unbuffered log,
so a run that does NOT crash is finally readable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With every authored watcher resolving, the pod run reaches the mission, draws
the FULL COCKPIT with the board booted and audio running, and exits GAME-RC=0
with no Fail. It builds no 3-D scene, and the engine says why:
Entity 1:1 class3035 couldn't figure out how to MakeEntityRenderables
L4VIDEO.cpp couldn't load object sky.bgf (then the whole arena)
NULL instance
VideoRenderer::MakeEntityRenderables (VIDREND.CPP:231) is the BOTTOM of a
virtual chain -- its comment says so outright -- and BTL4VideoRenderer does
not override it, so every entity falls through, no scene graph is built, and
the geometry that would hang off it never loads.
CORRECTS an earlier note in the roadmap: I recorded the first full-rig run as
proving '.BGF loading was never a reconstruction problem, only a missing
bridge', because the complaint count was zero. It was zero because the run
Failed at a watcher long before reaching geometry. Now that it gets there,
the loads are attempted and fail -- for want of renderables, not the bridge.
Next brick is the real btl4vid body, shape pinned by the surviving sibling
header RPL4VID.HPP: override MakeEntityRenderables, plus ReadSKLFile /
RecurseSKLFile to walk the .SKL skeleton pages into a dpl_DCS hierarchy and
load the .BGF geometry per node. Renderable content from the BT411 donor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under the documented rig (launch_pod.ps1 with the GL bridge up) the 'couldn't
load object' count went to ZERO. .BGF loading was never a reconstruction
problem, only a missing bridge.
What blocks now is a series of authored AttributeWatchers: BTL4.RES binds
them BY NAME and the engine Fails outright on any that does not resolve, so
each is a name our subsystems must publish. Five rungs climbed, each
run-verified: UnstablePercentage, SpeedEffect (Myomers table), AnimationState
+ CollisionState/CollisionNormal + ReduceButton (Mech), and the full Torso
table (all six authentic names mapped onto existing members).
The method that makes this cheap is banked: the shipped binary's string pool
carries each class's attribute names CONTIGUOUSLY in ID ORDER after the class
name, and in every case so far our member declarations sit in the same order
-- which confirms the layout and lets ids be pinned rather than guessed.
Intersecting those names with BTL4.RES gives the exact work list.
A THIRD miswired SharedData found on the way: Myomers carried Subsystem's
tables where it derives from PoweredSubsystem, the same defect as
MissileLauncher (5.3.33). Worth a sweep across every subsystem.
Staged member TYPES are provisional and documented as such: AttributeWatcherOf
reads *(T*)attributePointer and the instantiation comes from the resource,
which the string pool does not reveal. Nothing drives them yet, so settle the
types with the models that write them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run against the emulated Division card, our build reaches the mission scene
load and stops at the one method we stubbed:
btl4vid.cpp(42): BTL4VideoRenderer::LoadMissionImplementation
-- btl4vid.cpp not yet reconstructed
That proves MakeVideoRenderer, the BTL4VideoRenderer ctor over the engine's
DPLRenderer, the board boot (transputer + i860 firmware, ~858K wire
transactions through the VPX HLE) and Renderer::LoadMission all work in our
build. btl4vid is not a from-scratch climb; the engine half was always
linked and what is missing is the mission-load hook and its renderables.
Banked the reproduction rig (emulator/vidtest.conf + the host VPX env --
VPX_RESPOND alone is not enough, the iserver handshake dies without
VPX_RENDER), the authentic contract from CODE/RP/MUNGA/RENDERER.CPP:263, and
a note that the BT411 donor restructured this hook so the contract comes from
the 1995 engine and only the renderables come from the donor.
Also corrected today's earlier note: the transient 3-D frame seen during a
gauge run was NOT the dPL path. MakeVideoRenderer returns NULL without
DPLARG and no gauge conf sets it, so no video renderer exists in those runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gauge framebuffer is PLANE-PACKED: L4GAUGE.CFG gives each port a bit
mask, not a rectangle, so all six heads share the same x/y and are separated
only by bit -- the packing the VDB splits into the pod's physical screens.
Comparing RGB was therefore measuring nothing useful; the "magenta artifact"
just meant the wrong heads were lit at that pixel.
New instruments (emulator/render-bridge/gauge-ab/, with a README):
planes.py per-head scoreboard -- shipped vs ours, missing vs extra, per
port mask, recovering the 16-bit word from DOSBox's RGB565
BT_VIS_LOG a complete cockpit widget map from the single dispatch every
gauge widget is built through (MethodDescription::Execute),
printing name + port + authored position
ab.sh stages the fresh build over BTL4REC.EXE and kills any running
DOSBox before launching -- see below
The fix: the Comm page's pilot roster is the POD roster (the viewpoint mech's
ControlsMapper pilot array, mapper attributes 15/16), not the mission's
player list. It is zero in a solo mission, so the shipped page draws empty;
ours resolved the local player and painted its icon in colour 0xff. The gate
belongs in the row source, not PilotList::Execute -- Execute's empty branch
still has to run, because erasing is what the shipped page draws.
head shipped ours missing extra (was)
Eng1 17981 17981 0 0 (0 / 1030)
Eng2 17981 17981 0 0
Eng3 17981 17981 0 0
Comm 15656 13557 2099 0 (2099 / 1253)
Heat 21374 20913 566 105 (566 / 990)
Title-band magenta 404 -> 0. The 2099 Comm pixels we are missing were
missing before the gate, so it is a strict improvement.
Method note, which cost more than the bug: the rig's confs run BTL4REC.EXE
out of the mount while the build writes build410/btl4opt.exe, and nothing
connected the two. Three measurements ran an hour-old binary, so a change
that "did nothing" three times had never been tested -- and the correct first
hypothesis was discarded on that evidence. ab.sh now re-stages every launch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reading the art settled where the titles live: qsensors/qmyomers/qermlas/
qppc/qstrk6.pcc are 299x26 TITLE BANNERS, while the qblh0..7.pcc strings
the cfg hands vehicleSubSystems are 77x120 MECH ICONS (already lit by the
5.3.28 placement fix). So the authored auxScreenLabel IS the panel title
-- it just belongs on the MFD panel, not only the ENG page.
SubsystemCluster now builds a titleBanner child from the cached label.
Placement was MEASURED against the shipped framebuffer through the A/B rig
(BackgroundBitmap places by left/BOTTOM): a bring-up BT_TITLE_DY knob, then
a green-pixel correlation over the title band, then baked at x+0x09,
y+0xb3 from the panel origin. "SENSOR CLUSTER", "MYOMERS", "TYPE II 65
TONS" and both "ERMED LASER RANGE 500M" banners now render where the
shipped binary puts them.
Score progression vs the shipped cockpit on identical GAUGE content:
5.3.26 birth 90.1% identical / 82% coverage
5.3.28 authored screens 93.2% / 85%
5.3.29 titles tuned 94.8% / 91%
Also: grab.ps1 now focuses the target window before capturing -- the
screen-copy grabbed whatever was in front, which cost two junk captures
(a white frame and an unrelated 3-D app window).
Gauge fight 11/11 rounds and smoke both clean, zero faults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ran the reconstructed exe and the shipped ALPHA_1 binary against the SAME
GAUGE content, same mount, gauges on, and diffed the 640x480x16
framebuffers. OUR EXE DRAWS THE COCKPIT: 90.1% pixel-identical on the
first run, and every difference traced to a single root cause.
The authored aux-screen block (screen number / background placement /
label) lives in the subsystem resource -- which is freed with the Mech
ctor's stream buffer (the 5.3.24 use-after-free landmine). Both this tree
and the BT411 port had stubbed it, which is why BT411's displays show the
same artifacting. PoweredSubsystem now caches the block at ctor time with
accessors, and btl4gau2 uses the AUTHORED screen instead of a roster-order
stand-in and builds the placement strip art. Panels snapped onto their
authored screens -- weapon dials now land on the same panels as the
shipped exe, mech icons appear on the sensor/myomer panels, generator
letters correct -- taking the match to 93.2% identical / 85% coverage.
Remaining differences are donor-inherited stubs: the panel title art (now
proven to come from the q-strip statusImage path, not auxScreenLabel --
the label blit is wired and guarded regardless), the pilot name bitmap,
and some lamp frames.
The A/B rig is banked at emulator/render-bridge/gauge-ab/ (both confs +
the window-grab script) so the comparison is repeatable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the pod's 128x32 plasma readout in-fork, completing the self-contained
glass cockpit -- no vPLASMA process, no pipe.
- serialplasma.{cpp,h}: CSerialPlasma, a serial<n>=plasma backend that parses
the game's ESC P graphics stream (L4PLASMA streams whole changed rows;
receive-only, no replies) into a 128x32 1bpp framebuffer. Transcribed from
vPLASMA's PlasmaProtocol + VPlasmaDevice (graphics path); the factory
text-mode/fonts aren't used by the game and aren't rendered, but their escape
operands are still consumed so the parser can't desync. Seam PLASMA_GetFrame()
for the renderer.
- vpxlog.cpp: draw the framebuffer as an amber-on-black window in the explode
layout, lazily created once a plasma port exists, parked centered under the
lower-left MFD (VPX_PLASMA="x,y,w,h" overrides); WS_EX_NOACTIVATE so it can't
steal DOSBox focus.
- Register SERIAL_TYPE_PLASMA (serialport.h/.cpp, dosbox.cpp -- documented in
the vpx-device README step 3d, since those stock files live in the git-ignored
src tree).
- Confs: serial2 in the _rio confs + deploy templates switches from
namedpipe pipe:vplasma to the in-fork plasma.
Live-validated 2026-07-24. Real pods keep directserial realport:COM2; the
standalone vPLASMA app keeps namedpipe pipe:vplasma.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
net_diag.conf brought in line with the current dev-rig conf family (NIC
repoint era); LAST.EGG / FAILURE.LOG are the latest pod-run leftovers the
archive tracks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make the in-fork glass cockpit fully interactive and finish the lamp model.
- Click-to-press: clicking a button's region in a VDB head window presses that
RIO address into the game (lights white-hot while held, releases on mouse-up).
vpxlog rt_wndproc hit-tests the click against the bezel geometry (MFD 4 top /
4 bottom, radar 6 left / 6 right; the radar bottom indicators are display-only,
not clickable); head windows are tagged via GWLP_USERDATA. Clicks arrive on the
VPX render thread, so the seam serialrio RIO_HostButton() queues them under a
mutex and pollInput() drains them on the emu tick through the same incHold/
decHold path as the keyboard/pad -- so a click also lights the bezel.
- Flash blink: decode the lamp byte's flash bits (RioLampState 1 slow / 2 med /
3 fast) -- an unpressed flashing lamp now toggles between its brightness level
and off (half-periods 500/250/125ms); solid lamps unchanged, a press still wins.
- Focus: head windows are WS_EX_NOACTIVATE so a button click never pulls
foreground off DOSBox (which would demote the emu thread and stop SDL polling
the pad); a click uses MA_NOACTIVATE too. Do NOT enable SDL background joystick
events -- that raced the emu-thread controller poll against SDL's main-thread
pump and crashed the process.
Explode layout only, and only when a serial=rio port is present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Draw the cockpit RIO buttons around the mono-MFD and radar heads (explode
layout), lit by the host-commanded lamp state -- the display side of the
in-fork glass cockpit. Live-validated 2026-07-24 (operator).
- vpxlog.cpp: per-head button bezel in pal_draw, reading serialrio's new
RIO_GetPanelState seam. The 5 MFD heads get red buttons (4 top / 4 bottom,
100px tall tucked under a grown 640x500 display so a 10px lip shows); the
radar gets amber Secondary/Screen side columns (6x 104px each) plus a
centered bottom indicator strip (the 4 spares). Lamp byte decoded to
off/dim/bright (vRIO RioLampState, brighter of the two brightness fields);
a press shows white-hot. No labels -- the MFD shows each button's function.
Also swapped the two upper-outer MFD window NAMES to match their (already
position-swapped) desktop locations.
- serialrio.{cpp,h}: RIO_GetPanelState(lamps, pressed) accessor -- returns
false when no rio port, so non-rio configs and other heads render normally.
- pod-launch: in explode (dev) layout the Division bridge is a normal, freely
movable window -- not pinned topmost (Focus.cs) and nudged to 8,40 so its
title bar clears the top of the screen (its client was at 0,0, pushing the
frame off-screen). Cockpit/kiosk keeps the topmost + 0,0 borderless look.
Only affects the explode layout and only when a serial=rio port is present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old Network Bridge adapter (DB5521D) no longer exists on the dev rig;
pcap now binds the physical Realtek directly. Update the tracked rendered
confs so a networked launch from the repo tree reaches the host. Symptom of
the stale value was the guest looping "NO PACKET DRIVER FOUND" (pcap open
fails -> NE2000 absent -> ODI stack can't bind). net_diag.conf left alone
(unrelated pre-existing churn).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Emulate the cockpit RIO board natively inside the DOSBox-X fork, driven by
an SDL game controller + host keyboard -- no external vRIO process and no
named pipe. The game<->board round trip runs in-process at the emulated
UART's own cadence, so the serial round-trip dropouts (livelock, TXMAXIDLE,
rxburst, 15s retry) can't occur.
- serialrio.{cpp,h}: RIO 9600-8N1 device state machine + protocol codec +
input mapping, transcribed from the vRIO app (VRioDevice + Protocol +
InputRouter/BindingProfileFormat), minus transport/pacer/UI/locks.
B0 = gamepad (5 axes + joystick column). B1 = keyboard field: MFD banks on
the letter rows, F-keys = Secondary/Screen, numpad = flight controls,
LShift/LCtrl throttle slew; PAUSE/ScrollLock toggle panel<->DOS; vRIO-
grammar bindings file via bindings:/VWE_RIO_BINDINGS.
- vpx-device/README.md: device notes + apply steps (the DOSBox-X src tree is
git-ignored, so this dir is the tracked source of truth; the three sdlmain
keyboard-hook edits are documented there).
- net_{loop,rp}_rio.conf + deploy templates; render-bridge/
gauge_arena_rio_sound.conf (standalone -egg trim); pod-launch --rio
(mutually exclusive with --pipe).
Additive: real pods keep directserial realport:COM1; vRIO-over-pipe keeps
namedpipe pipe:vrio. Validated live 2026-07-22 (pad + keyboard field +
console networking) on the dist install.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records tonight's finding before stopping this thread: the user correctly
pushed back on an oversimplification (the renderer isn't a purely passive
wire tap -- the protocol has a real synchronous pick-query shape, sect_pixel/
sect_vector, where the board runs its own Moller-Trumbore ray-triangle test
and replies with range+identity). Checked directly against the REAL firmware
(not just the reference software renderer's notes):
- sect_pixel/sect_vector never appear in this capture -- unused by this build.
- Action 0x23 (the notes' fire/pick candidate) gets no reply from the real
firmware either (checked via the h_reply hook, which fires on ANY
firmware-issued reply) -- but its payload doesn't match the documented
FLYK format at all (no screen coords; a counter-tagged discharge-event
shape instead), and its wire context (list_remove + zone-color + a
PSFX-shaped 0x1d) reads as a weapon-discharge notification with visual
consequences, not a pick query.
Net: open question, not resolved. BT4.10/MUNGA's real pick/range mechanism
(if board-side at all) is not yet identified. Paused per the user rather
than continue an open-ended search without a specific pointer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-reported live: the torso-twist tape appeared at the TOP of the reticle
(should be bottom) and the range ladder filled the wrong direction. I had
assumed dpl2d used a standard math y-up convention (y0=bottom, y1=top) and
inverted accordingly.
Found the exact, already-solved answer in vrview_gl.py's own HUD vertex
shader comment: "dpl2d Y grows DOWN (screen convention): period VHS footage
shows the twist dial UNDER the reticle and the range bar filling top-to-
bottom; mapping straight onto GL's Y-up NDC rendered the whole HUD flipped"
-- the exact same bug, already diagnosed and fixed there. y0 (the smaller
raw view-rect value) is the TOP of screen, y1 the bottom -- direct mapping,
no inversion needed.
Verified offline: the composited reticle now shows the twist tape (with its
bowtie shape) at the bottom and the range ladder/pips reading top-to-bottom,
matching BT411's documented layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrates hud2d_overlay.HudTracker into the live pipeline: fed exactly one
wire record per firmware consumption (synchronized with r.qi, not just queue
arrival, so hud2d_root's view/display-list state matches what the firmware
has actually processed at render time), composited onto each rendered frame
before present.
Verified via the same integration pattern (truncated direct-queue boot, no
socket overhead) against netdeath-20260708.fifodump: the reticle composites
starting at wire command 22906 (frame 22) -- identical resolution point and
visual result to the standalone hud2d_overlay test. A full-mission run
through the live socket path is genuinely slow around this capture's "battle"
content (multi-billion firmware steps per command in that section, pre-
existing and unrelated to this change) but not stalled -- confirmed by
reaching the same cmd/frame numbers via the direct path in 14.7s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The user asked to investigate why HUD never renders, reasoning it would also
inform the weapon-fire/targeting question. Root cause: the cockpit HUD (reticle
+ weapon pips) is NOT part of the 3D scene-graph wire traffic this renderer has
been parsing all session -- it's a completely separate 2D display-list protocol
(dpl2d_*, wire actions 0x29 NewDisplayList / 0x2b FlushDisplayList), which this
pipeline has never looked at.
BT411's context/gauges-hud.md + phases/phase-02-dpl2d-reticle.md already fully
documented this from the game's decompiled source (BTReticleRenderable, the
dpl2d opcode table). More directly useful: dpl3-revive/patha/vrview.py (the
OTHER renderer) already has a COMPLETE, TESTED interpreter for the wire-level
opcodes (hud2d_prims/hud2d_root) -- reused directly here rather than re-derived.
hud2d_overlay.py: HudTracker feeds incrementally from the same wire records
already flowing through this pipeline (minimal node type+body tracking, the
dcs->view list_add edge, and 0x29/0x2b display-list accumulation -- NOT a full
scene-graph, just what hud2d_root/hud2d_prims need). composite_hud() maps the
resolved primitives from view-rect space (x0..x1, y0..y1, y up -- confirmed
live: x[-1,1] y[-0.615,0.615], hither=0.25/yon=1300/zeye=1.732=cot(30 deg) --
independently cross-validating the earlier fog-formula derivation from live
wire data, not just the static BTDPL.INI) into pixel space and draws them.
Verified offline (netdeath capture): the HUD binding resolves at wire record
~22661 (later in the mission than earlier test frames, which is why it never
appeared in this session's spot-checks); composited frame shows a green
crosshair + tick-mark cross, a ring, and a right-side range ladder with
weapon-pip circles -- an exact match for BT411's documented reticle layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Narrative on-ramp before the register-level RE: two independent video
systems, the 16bpp byte-lane split (low=radar / high=5 mono MFDs), the
three Bt477 heads, and the octopus fan-out to the six cockpit displays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vrend410.mng: the production firmware, a HARD dependency of the committed
live pipeline (emu_main.py MAPS['vrend410'].fw loads it) -- the repo couldn't
run the live renderer without it.
- txdn_probe.py / texstore_trace.py / texel_trace.py: standalone reverse-
engineering probes (texture-source/DRAM-write/texel tracing) documenting the
texid-binding and texel-format investigations.
- .gitignore: exclude the firmware-decomp run artifacts (emu860c.pyd rebuildable
from source, *.log/*.pkl/*.png renderer outputs + caches), editor junk, and
the game runtime state/logs written by running the pod.
Left untouched deliberately: the parallel Mech session's in-progress
restoration/source410/BT/{EMITTER,BTPLAYER}.CPP edits (theirs to commit) and
the tracked game runtime state (LAST.EGG/CTMIX.CFG etc. -- transient).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per user request, bumped the cockpit MFD/radar windows from 0.6 to 0.75 of
native 640x480 (MFD 384->480, radar 288->360 logical). Live-verified on the
secondary: MFDs now render 331x266 (was 267x218, ~+24% after the primary's DPI
downscale), radar 251x346, all six still fit the 1707x1067 secondary with
margin, renderer unchanged on the primary. No wire stall.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-reported: a hard black strip at the horizon, between the arena walls and
the sky geometry. Those pixels aren't covered by any polygon -- the game relies
on the board's own background clear there (the sky dome doesn't extend down to
the wall tops). Our renderer cleared the framebuffer to black, so the gap read
as a black band.
Fill the background with the FOG far-color instead: anything no polygon covers
is at effectively infinite distance = fully fogged, so the horizon gap now
blends seamlessly into the same haze the distant geometry fogs toward (filled
far pixels -> FOG_RGB via the fog blend; unfilled background = FOG_RGB directly
-> continuous). Offline-verified: the black strip becomes a smooth blue-purple
haze band. (The game's actual view-flush back_color may differ slightly from
the fog far-color; the fog color is the physically-consistent proxy until
back_color is parsed off the wire -- noted in code.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds -Explode: the pentapus cockpit instruments (radar + 5 mono MFDs, the VDB
video-splitter decode) render on the SECONDARY monitor while the reconstructed
i860+GPU 3D renderer stays the main out-the-window view on the primary. The
window rects auto-fit to whatever the non-primary display is (detected via
System.Windows.Forms.Screen, so it survives a monitor rearrange), scaled to
~0.6 and offset into that display's virtual-desktop region; the DOSBox SVGA
screen parks clear of the cluster.
The cockpit windows live in vpxlog's rt_main thread, which only starts under
VPX_RENDER=1 -- the same flag whose DOSBox-thread scene-graph decode previously
stalled wire delivery (fixed earlier by removing it). Feared this would bite
again; MEASURED and it does NOT: with VPX_EXPLODE + VPX_NOMAIN the heavy main-
window 3D render (rt_draw, gated on the main window existing) is skipped, so
rt_main only does the cheap per-tick MFD/radar blits and the DOSBox thread keeps
up. Live-verified: all 6 cockpit windows placed on the secondary (DISPLAY1),
renderer on the primary, wire advancing normally to cmd 23k+ (no command-8
stall), frames flowing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BTDPL.INI's fog values for this mission (testarn.egg = arena1/day/clear,
[ardayclear] leaf: fog=200.0 1250.0 0.32 0.3 0.5, clip=0.25 1300.0) are exact,
verified ground truth. The depth term took two tries: `tz` (texz) looked like
the right candidate (BT411 says fog is linear on "W") but measured live it
DECREASES with distance -- the wrong direction/shape. Re-derived from the
actual firmware source instead of guessing again: AS860/XFPROJ.SS's own
comment on the projection code ("pz=wz*invz, where pz=1 at near clip, 0 at
infinity") establishes the z-buffer value as a normalized hither/distance
quantity, giving distance = hither * 2^20 / raw_zbuf_value.
gpu_raster.py: added a 10th shader output channel carrying the raw z-buffer
float (previously only a filled/empty flag existed), and the fog blend using
this formula + BTDPL.INI's exact color. Verified offline: distance climbs
smoothly (394->1051) across a receding ceiling, landing inside the authored
200-1250 range with no further fudging; visually the horizon shows the
authored blue-purple haze, correctly absent near the camera.
IG-SHADING-MODEL.md: documented the false start (tz) and the source-grounded
fix, with the honest caveats that remain (2^20 exact scale, hither as a fixed
per-mission constant vs. per-view from the wire).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the existing shading-model reference (already the established pattern
for this cross-check) with tonight's live-pod findings, explicitly framed for
the BT411 modernization team's consumption per the user's stated purpose for
this whole pipeline: a hardware-grounded source of truth where the software
renderer has open or hard-to-fix questions.
Added: a cross-reference index into BT411's context/ docs (rendering.md,
open-questions.md, combat-damage.md); the lit-color-plane finding (real
mechanism, confirmed values, wrong when used as a texture tint -- reverted
same session); fog (BT411's complete model, not yet ported here); and honest
open/uninvestigated status for HUD, weapon-fire rendering, and cockpit-cage,
each pointing at where BT411 has probably already solved the adjacent
question so it isn't re-derived from scratch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found my own prior documentation (IG-SHADING-MODEL.md sec 2, written earlier
this session before compaction) that already establishes: the wire's 0x1a
texture uploads arrive ALREADY colourised (the material ramp is pre-baked on
this path, per the shipped libDPL header model cross-referenced against
BT411's context/rendering.md). Tinting them again with the lit-color plane
double-colours the result -- exactly the "more colors but neither correct"
outcome reported live (sky/ground now differ, but neither right).
Keep the lit-color plane for FLAT (untextured) polys only, where it replaces
a hardcoded placeholder with real per-polygon data; textured polys sample
unmodified, as documented. The underlying r24/g24/b24 mechanism itself is
still real and worth understanding precisely (see open-questions tracking),
but applying it as a texture tint was the wrong model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Image.fromarray(...).save() ran on EVERY frame regardless of whether a
window was already displaying it -- pure disk-I/O overhead adding to the
render-vs-game latency gap. Only write PNGs when there is no window (the
offline/headless case, where they are the actual output).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three real issues found while watching the live-render session against the
actual running game and fixed:
1. STALE-FRAME SKIP (live_render.py): the renderer treated every single
draw_scene as a frame to fully render+present. The game ticks at a
measured ~28 draw_scene/s (matches the documented FAST 28Hz SOS clock),
faster than our render, so a backlog built continuously -- we were
perpetually rendering OLD content, which read as "frozen or 30s+ behind."
Fix: SockSource.has_pending() detects an already-buffered next record;
when backlogged, skip render+present (still consume/step normally) so we
always show the freshest available state instead of crawling through
history. pump() now drains aggressively so the backlog check is honest.
2. MID-MISSION CATCH-UP (live_render.py --catchup): reconnecting to an
already-running pod produced a black screen. Root cause: draw_scene only
means "render what's already loaded" -- it carries no geometry itself.
The socket tee forwards only NEW traffic to a fresh client, so a from-
scratch firmware boot has an empty scene graph. Fix: replay vpxlog's
archival VPX_FIFODUMP (a second, independent sink of the same wire,
written since pod boot) as the initial queue before going live -- exactly
the same catch-up vpxlog's OWN native bridge already relies on for this
same reason.
3. LIT-COLOR PLANE (gpu_raster.py): user-reported "textures are mostly
grayscale" and "no fog effect." Found the actual mechanism in the real
firmware source (sda4 DIVPXMAP.H + EOF.C): TREEclmpintoMEM (op 0x5a)
writes dvpx_r24/g24/b24 -- a per-polygon lit color -- which EOF.C later
copies into dvpx_eofr/g/b (the final screen color) via a straight 24-bit
CPY in the simple (unfogged) case. Our renderer decoded this instruction
but never used it, instead hardcoding flat polys to a placeholder
(60,60,70) and sampling textures with no lighting modulation at all.
Verified live on the wire: the value is a DIRECT 0-255-ish brightness
(not a [0,1] float to rescale -- observed range ~49..395 with real
per-polygon variation, only the brightest few clipping), consistent with
a straight clamp-to-8-bit write. Now decoded (addr 118/126/134, empirically
confirmed against DIVPXMAP.H's r24/g24/b24 spacing) and applied: flat polys
get the real lit color instead of the placeholder; textured polys are
tinted by it (texture * lit/255). Offline-tested (no crash, visibly
plausible result: a lavender-tinted floor with real near/far gradient
replacing the flat gray-blue placeholder) -- NOT yet cross-checked
against a ground-truth screenshot, so treat the exact hue as provisional
even though the underlying mechanism is verified real.
Honest scope note: this changes gpu_raster's rendered colors on purpose, so
it no longer bit-matches the OLDER frame_*.png CPU reference from before this
fix (that reference predates lit-color decode). The geometry/perspective/
raster correctness those bit-identity tests proved is unaffected -- only the
color post-process changed, layered on top.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-visible bug: connected the reconstructed renderer to the REAL running pod
for the first time (M4d demo) and the render fell increasingly behind the live
game, plus looked near-grayscale. Root-caused with direct profiling instead of
guessing:
1. VPX_RENDER=1 was set in launch_live.ps1 (copied from launch_pod.ps1's
defaults without thinking it through) -- it starts vpxlog.cpp's OWN native
GL render thread (rt_main), which contends with our renderer for the same
CPU/GL resources. Live-caught: with it set, wire delivery stalled at wire
command 8 for 90+s; removing it, the same mission reached command 21,488
in the same window. This was the dominant cause of the visible lag.
2. gpu_raster.build_prims() scanned the whole per-draw program window
(0x08158000-0x08170000, ~24k words) via a Python-level r32() call per word
every frame. Added emu860c.dump_range(lo,hi)->bytes (one bulk C read) and
rewrote build_prims to index a numpy array instead -- verified byte-identical
dump_range output first, then reconfirmed the whole render is STILL
bit-identical to the CPU reference (frames 0/5/11, differ>24 = 0.000%).
3. Profiling after (1) showed the per-texture compositing loop was the real
remaining cost: 62ms/frame doing full-832x512-frame fancy-indexed texture
sampling for EACH of 13 textures, regardless of how few pixels use each one.
Added dpl_sampler.composite_masked (sample+composite only the pixels a
texture actually covers; verified equivalent to composite() on a partial
mask in dpl_sampler's own conformance test) and hoisted the invariant
out of the loop.
Combined: render time 95.8ms -> 54.9ms/frame (~1.75x from this change alone,
~2.1x vs the original ~117ms/frame baseline), still bit-identical to the CPU
reference throughout.
launch_live.ps1: removed VPX_RENDER, renamed the automatic-variable shadow
-> (flagged by PSScriptAnalyzer), added -Pin <fifodump> to
pre-load the full texture set (bit-identical to offline) instead of the
incremental default, for a demo that wants correct color immediately rather
than waiting on the live mission's own upload order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These changes were made and verified earlier this session (the all-cyan bug
fix is already recorded as SOLVED in project memory) but never got committed --
render_final.py has referenced dpl_sampler as a module in its docstring since
ac4142e7, yet dpl_sampler.py itself was never added, leaving the repo unable
to run the M4b/M4c/M4d renderer scripts that all import it.
texstore.py: fix the texel word format. SVT texels are [pad,B,G,R] (ground
truth: dpl3-revive patha/vrboard.py do_texels + stage_assets.py); reading RGB
as bytes (0,1,2)=(pad,B,G) zeroed red, producing the all-cyan render. Correct
read is bytes (3,2,1).
dpl_sampler.py: the authentic IG-board texture-value model (wrap repeat/clamp,
near-black CUT keying) factored into its own tested module, distilled from the
shipped libDPL headers -- see IG-SHADING-MODEL.md (also added, referenced by
render_final.py's docstring but likewise never committed).
igc_exec.py: OP48/OP49 candidate opcodes (pixel global-X/Y seed into mem) added
to the CPU golden-model executor alongside the existing SCMEMA seed handling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VPX_RENDER=1 was copied in from launch_pod.ps1's defaults without thinking it
through -- it starts vpxlog.cpp's OWN native GL render thread (rt_main),
which is redundant with (and contends against) our reconstructed renderer
riding the FIFOSOCK tee. Live-caught: with it set, wire delivery stalled at
command 8 for 90+s (window rendered, but slowly/poorly per live observation);
removing it, the same mission reached command 21,488 (10 frames) in the same
window. My two earlier successful ad-hoc bring-up tests had already removed
this env var by hand -- the script just hadn't caught up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>