Files
TeslaRel410/restoration/source410/RENDER-ROADMAP.NOTES.md
T
CydandClaude Fable 5 e97862507c BT410 5.3.74: the faulting instruction decoded -- a handler-chain walk with a bad node
The SegPhys-corrected dump read real code at last (the earlier zeros were the
probe reading EIP as a bare linear address; CS=00FF has a base):

    66D4  push 0 / push edi / push ebx / push esi
    66D9  call dword near [ebx+4]     <- faults
    66DC  cmp eax,0 / jz done
    66E5  mov ebx,[ebx] / jmp loop

A linked-list walk with a per-node callback -- node = {next@+0, handler@+4},
four args, "handled" on nonzero.  A DPMI exception/interrupt handler chain.

The arithmetic closes exactly: the read is DS_base + ebx + 4, and
7000FA64 - F000CA64 = 80003000, so DS base is 0x80003000 and the famous cr2
is that wrap.  My earlier '[EBX+0x3004]' reading was fabricated from the cr2
alone -- there is no 0x3004 displacement, which is why the constant appeared
in no binary.

So: a chain node's NEXT pointer holds F000CA60, the value parked in unhooked
IVT slots -- the walk expected a terminator and got an interrupt vector, then
read its +4 as a function pointer.  Identical registers on every catch, so
one deterministic path, entered often enough that our 60s load window catches
it 25-40% of the time while shipped's 15s window mostly does not.

Open: which routine owns the chain, and where the bogus next came from.  The
probe now walks the EBP frame chain to name the caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:34:22 -05:00

1677 lines
96 KiB
Markdown

# The render roadmap — from headless to visible (researched 2026-07-24)
Four-dossier workflow synthesis (card protocol / video-TU delta / emulator
side / gauge path). The headline: **the render seam is far closer than
expected** — the engine half of BOTH render paths already compiles and links
into our btl4opt.exe from the CODE originals, and the emulator's board-side
device is EXE-agnostic.
## The two independent render paths (either can run without the other)
1. **The 3D main view (division card)**: the game speaks dPL — a
RETAINED-mode scene graph API (never pixels, never transformed polys) —
through the statically linked libDPL, which frames every call into
vr_action messages on the INMOS C012 link adapter at port 0x150
(FIFO fast path: body as REP OUTSW words to 0x154, trailing length word
to 0x151 commits). Gate: `DPLARG` env (set by SETENV.BAT when
L4VIDEO != OFF); NULL DPLARG = the headless shape we run today
(`MakeVideoRenderer()` returns NULL, tolerated).
- Per-frame submission: **engine code** — `DPLRenderer::
ExecuteImplementation` (CODE/RP/MUNGA_L4/L4VIDEO.CPP:5065) walks the
dplRenderableSocket; renderables (L4VIDRND.CPP, ~30 classes) set DCS
matrices; `dpl_FlushDCSArticulations` = wire action 0x1f (batched
poses, absolute 3x3+T or [sin,cos] joint records); `vr_draw_scene`
(9) kicks the frame; `velocirender_sync` (0x2d, exe @0x48D220) is the
ack-less bulk-op fence.
- **Our build ALREADY COMPILES this whole engine layer** (mungal4.lib:
l4video/l4vidrnd/l4dplmem + prebuilt 1995 STARTDPL.obj + LIBDPL.LIB;
see build410 obj logs). The missing piece is GAME code: **btl4vid**
(BTL4VideoRenderer — MakeEntityRenderables/MakeMechRenderables/
effects/wreck swap/material substitution; BT411 donor 3039/848 lines
with per-method @ADDR evidence, binary extent @4cc40c..@4d2bbc; our
stub Fails at LoadMissionImplementation).
2. **The gauge path (cockpit instruments)**: completely separate hardware —
the PC's own SVGA (VESA 0x111, 640x480x16, L4SVGA16.ASM banked blits),
split across VGA heads by the VDB board (port 0x300, palette groups; the
octopus-cable 5-mono-MFDs + color-radar decode). Gate: `L4GAUGE` env.
The plasma readout is a gauge-renderer graphics port (PlasmaDisplay on
COM2), not a separate renderer.
- Engine half (GaugeRenderer/L4GaugeRenderer/the 5171-line L4GAUGE
widget interpreter/GAUGMAP/L4VB16) already compiles + links in
build410; one documented deviation (interpreter table 46864 -> 262144
for BT's larger CFG).
- Missing: the 6 BT gauge TUs — **btl4gaug (130 fn) / btl4gau2 (77) /
btl4gau3 (46) / btl4rdr (36) / btl4grnd (13, ours is a 4-fn stub) /
btl4galm** — ~302 functions/~43KB, binary extent 0x4c19fc-0x4cc40c,
the largest function-count block left in btl4.lib. BT411 donors are
COMPLETE (semantic .cpp/.hpp with @ADDR evidence, ~5900 donor lines);
the work is RE-HOSTING onto 1995 engine APIs, not fresh decompilation.
Class inventory per TU in the dossier (widget primitives / composite
clusters / MP-HUD / radar MapDisplay / the renderer / alarm manager).
## The emulator side is READY and EXE-agnostic
The DOSBox-X fork's VPX device (emulator/vpx-device/vpxlog.cpp, ports
0x150-0x161 + VDB 0x300-0x31A) implements the BOARD side purely at the wire
level: blind firmware absorb, iserver handshake (VPX_RESPOND=1), generic
acks, the 0x2d sync echo, draw_scene frame-acks with a REAL raycast reticle
pick against its own decoded scene, and the GL renderer (VPX_RENDER=1) that
draws the main view + cockpit windows. It never reads game memory and keys
only on wire contents — **our reconstructed exe drives it unmodified once it
emits the same traffic**. The AWE32 pair is mandatory for any timed run
(SOS FAST clock); gauges also render live on the emulated S3 with the
shipped exe (GAUGE-NOTES.md: HEAPSIZE=15000000 + granularityInKB=64).
## The plan (order of value)
1. **Gauges-only first** (decisively cheaper): set `L4GAUGE=640x480x16` (+
HEAPSIZE), reconstruct the 6 gauge TUs from the BT411 donors. Built-in
oracle: the shipped exe renders reference output from the SAME GAUGE
content — per-head diffable. Live instruments need the attribute
sources, which the 5.3.x waves have largely built (weapon states, heat,
power, damage, radar).
2. **Then btl4vid** (the 3D world renderer) + DPLARG on: the engine
DPLRenderer + libDPL do the wire; btl4vid builds the renderables per
entity; verify against the VPX device's GL window and the dpl3-revive
bridge.
3. The review/playback pair (btl4pb + the BTL4.CPP review branch) closes
the last absent TUs.
Message-id / protocol quick refs: vr_create=1 (host-assigned handles),
vr_flush=3 (node structs; VIEW proves 832x512), geometry 0x17/0x19/0x1a
(texel word [pad,B,G,R]), artics 0x1f, draw 9, sync 0x2d, fire 0x23,
reticle 0x26. DPLARG reference value in emulator/baseline.conf:21.
================================================================================
2026-07-27 -- THE 3-D PATH RUNS TO MISSION LOAD (measured, not estimated)
================================================================================
Our reconstruction, run against the emulated Division card, gets ALL THE WAY to
the mission scene load before it stops -- at the one method we stubbed.
c:/.../bt_l4/btl4vid.cpp(42): BTL4VideoRenderer::LoadMissionImplementation
-- btl4vid.cpp not yet reconstructed
What that single line proves is working end to end in OUR build:
* BTL4Application::MakeVideoRenderer builds a BTL4VideoRenderer
* the BTL4VideoRenderer ctor chains the engine's DPLRenderer
* the board boots: transputer + i860 firmware upload, ~858K wire
transactions logged by the VPX device HLE
* Renderer::LoadMission runs (RENDERER.CPP:273) and calls the hook
So btl4vid is NOT a from-scratch climb. The engine half was always linked;
what is missing is the game's mission-load hook and the renderables it builds.
HOW TO REPRODUCE (this is the rig, and it is cheap):
emulator/vidtest.conf -- baseline.conf with the mount made absolute and
BTL4REC.EXE (our build, staged beside the shipped BTL4OPT.EXE which is NOT
touched) instead of the shipped exe. Host env before launching dosbox-x:
VPX_RESPOND=1 VPX_RENDER=1 VPX_NOMAIN=1 VPXLOG=<file> VPX_DUMPDIR=<dir>
VPX_RESPOND alone is NOT enough -- with only that, the board boot dies at
"Protocol error : length 65535 too big / rcv_protocol fail during iserver
handling", which is the iserver handshake reading a floating bus.
VPX_NOMAIN=1 keeps it from opening windows; add VPX_EXPLODE=1 only when you
want the 7-display pentapus. The full documented pod launch is
render-bridge/launch_pod.ps1 (LAUNCH.md) when the GL bridge is wanted.
THE CONTRACT for the next brick (authentic, CODE/RP/MUNGA/RENDERER.CPP:263):
Renderer::LoadMission verifies InactiveRendererStatus, sets
LoadingRendererStatus, stamps nextRenderTime, calls
GetRendererManager()->StartRenderer(this), THEN LoadMissionImplementation.
The base implementation Fails ("should never reach here") -- every concrete
renderer must override it.
DONOR: BT411 game/reconstructed/btl4vid.cpp, 3188 lines / 27 methods, carrying
the renderable family (BTReticleRenderable, BTTranslocationRenderable, the
pending-wrecks map). NOTE it does not contain a LoadMissionImplementation by
that name -- the WinTesla port restructured the hook -- so take the CONTRACT
from the 1995 engine above and the CONTENT from the donor's renderables.
CORRECTION to the note earlier today: a transient 3-D cockpit frame captured
during a gauge-rig run was NOT evidence of the dPL path emitting anything.
MakeVideoRenderer returns NULL unless DPLARG is set (BTL4APP.CPP:152), and no
gauge conf sets it -- so no video renderer exists in those runs at all. That
frame was almost certainly attract/loading art out of BTL4.RES. The evidence
above is the real thing, and it came from setting DPLARG deliberately.
--------------------------------------------------------------------------------
TWO RUNGS CLIMBED -- the 3-D pipeline now runs end to end to the geometry load
--------------------------------------------------------------------------------
RUNG 1: BTL4VideoRenderer::LoadMissionImplementation, Fail -> bring-up no-op.
A no-op is a LEGAL body, not a cheat: the engine's own
VideoRenderer::LoadMissionImplementation (CODE/RP/MUNGA/VIDREND.CPP:259) is a
bare Tell, and our working GaugeRenderer (GAUGREND.CPP:3275) ships the same.
The renderer comes up and runs its frame loop with an EMPTY scene, which is
what we wanted to measure before writing content.
RUNG 2: Mech::EyepointRotation PUBLISHED. With the renderer live the engine
immediately Failed at
l4video.cpp(5004): DPLRenderer::SetupCull jointed mover had no
EyepointRotation attribute
SetupCull (CODE/RP/MUNGA_L4/L4VIDEO.CPP:4990) fetches "EyepointRotation" BY
NAME off the viewpoint entity and, for a JointedMover, composes it with the
"siteeyepoint" segment to build worldToEyeMatrix. Our mech already HAD
EulerAngles eyepointRotation with a getter (MECH.HPP) -- only the attribute
publication was missing. One enum id + one table row. (The siteeyepoint
check passed first time, so that segment was already streaming correctly.)
WHERE IT STOPS NOW, and why that is not a defect:
L4VIDEO.cpp couldn't load object buttee.bgf (x123, also dg100/mslr)
NULL instance
ZERO Fails, zero exceptions -- the run reaches the full mech build (all 7
weapons), constructs the renderer, boots the board (~907K VPX wire
transactions), loads the mission, runs the per-frame cull and gets all the way
into geometry loading. The models ARE present (VIDEO/GEO/BUTTEE.BGF etc, 879
BGFs) and BTDPL.INI points at them (objectpath=. ideo\geo), but loading a
.BGF goes through the board to the EXTERNAL GL render bridge that the VPX HLE
tees over VPX_FIFOSOCK -- and that bridge was not running. "NULL instance"
comes from the prebuilt LIBDPL.LIB, i.e. the library refusing to instance an
object that never loaded.
NEXT RUNG: run under the documented full rig (render-bridge/launch_pod.ps1,
see LAUNCH.md) so the bridge is up and geometry actually loads. Only then is
it worth writing real MakeEntityRenderables content -- until the bridge is in
the loop there is nothing to see even if the scene graph is correct.
--------------------------------------------------------------------------------
THE FULL POD RIG: geometry loads, and the ladder is now the AUDIO WATCHER SET
--------------------------------------------------------------------------------
Run under the documented rig (render-bridge/launch_pod.ps1 with the GL bridge
up) the geometry complaint COUNT WENT TO ZERO -- .BGF loading was never a
reconstruction problem, only a missing bridge, exactly as predicted.
emulator/render-bridge/gauge_arena_rec.conf = gauge_arena_sound.conf with
the exe swapped to BTL4REC.EXE (our build, staged into ALPHA_1/REL410/BT
beside the shipped BTL4OPT.EXE, which is NOT touched).
Launch: .\launch_pod.ps1 -Conf ...\gauge_arena_rec.conf
What blocks now is a SERIES of authored AttributeWatchers. BTL4.RES binds
watchers BY NAME and the engine Fails outright on any that does not resolve
(WATCHER.CPP:141) -- these are the sound triggers, so every one of them is a
name our subsystems must publish.
RUNGS CLIMBED THIS PASS (each one run-verified, ~6 min per cycle):
UnstablePercentage Mech, staged member (no instability model yet)
SpeedEffect Myomers -- member existed, table published
AnimationState Mech -- member existed (bound 307x in the RES!)
CollisionState/Normal Mech -- state existed, normal staged
ReduceButton Mech, staged
SpeedOfTorsoHorizontal Torso -- all six names mapped to real members
THE METHOD THAT MAKES THIS CHEAP (use it instead of one rung per run):
The shipped binary's string pool carries each class's attribute names
CONTIGUOUSLY, in ID ORDER, right after the class name and its message names.
So: strings -a BTL4OPT.EXE | grep -n -A14 '^Torso$' gives the authentic
table for that class, and in every case so far our member declarations sit in
the SAME ORDER -- which both confirms the reconstruction's layout and lets the
ids be pinned rather than guessed. Cross-reference against the resource with
comm -12 <(strings BTL4.RES|grep -x '[A-Z][A-Za-z0-9]\{3,\}'|sort -u) <(strings BTL4OPT.EXE|...|sort -u)
which returns 59 candidate names; the attribute-like ones are the work list.
STILL TO PUBLISH (from that intersection, owner in brackets where known):
MotionState [Torso? -- sits between StickPosition and
TorsoHorizontalEnabled in the pool, but that region MIXES attribute
names with resource-field names, so confirm before publishing]
LocalVelocity, LocalAcceleration [Mover/Entity]
AmmoState [AmmoBin] CondenserState [Condenser]
ReservoirState [Reservoir] GeneratorState [Generator]
SimulationState [Subsystem] DisplayMode [ControlsMapper]
LookForward/Left/Right/Down/Behind [MechControlsMapper]
LaserOn, ReportLeak, FireCountdownStarted, TargetRangeExponent,
ConfigureActivePress, StickPosition
A THIRD MISWIRED SharedData FOUND ON THE WAY: Myomers carried
Subsystem::MessageHandlers + Subsystem::AttributeIndex where it derives from
PoweredSubsystem -- the same defect as MissileLauncher (5.3.33), dropping
every powered attribute AND the powered message handlers. Torso is wired to
Subsystem's too, but there the chain (PowerWatcher -> HeatWatcher ->
MechSubsystem) genuinely publishes nothing, so only its AttributeIndex moved.
WORTH A SWEEP: check every subsystem's DefaultData against its actual parent.
TYPES ARE PROVISIONAL on the staged members. AttributeWatcherOf<T> reads
*(T*)attributePointer (WATCHER.HPP:288) and the instantiation comes from the
resource, which the string pool does not reveal. Nothing drives these yet so
no watcher can fire -- settle the types WITH the models that write them.
--------------------------------------------------------------------------------
THE WATCHER LIST IS NOW FULLY KNOWN -- stop discovering it one run at a time
--------------------------------------------------------------------------------
BTL4.RES stores each watcher as a (SUBSYSTEM, ATTRIBUTE) string pair, so the
whole list can be read straight out of the resource instead of one 6-minute
run per name:
for each candidate name, find its NUL-terminated occurrences in BTL4.RES
and take the printable string immediately before it -- that is the owning
subsystem. Candidates = strings present in BOTH BTL4.RES and BTL4OPT.EXE
matching ^[A-Z][A-Za-z0-9]{3,}$ (59 of them).
Normalised across instance names (ERMLaser_1/2/3 -> the weapon class,
AmmoBinSRM6_1 -> AmmoBin, Condenser1..6 -> Condenser, GeneratorA..D ->
Generator), the COMPLETE set the engine will demand is:
Entity AnimationState(306) LocalVelocity(324) SimulationState(74)
CollisionSpeed(72) CollisionState(54) FootStep(36)
IncomingLock(36) UnstablePercentage(36) LocalAcceleration(18)
DistanceToMissile(18) ReduceButton(18) CollisionNormal(18)
weapons WeaponState PercentDone LaserOn ReportLeak
ConfigureActivePress SimulationState
AmmoBin AmmoState FireCountdownStarted SimulationState
Condenser CondenserState ReportLeak
Generator GeneratorOn GeneratorState ReportLeak
Reservoir ReservoirState
Myomers SpeedEffect ConfigureActivePress ReportLeak SimulationState
Avionics(Sensor) ConfigureActivePress ReportLeak SimulationState
ControlsMapper DisplayMode Look{Forward,Left,Right,Down,Behind}
TargetRangeExponent
Torso SpeedOfTorsoHorizontal MotionState
HeatSink CurrentTemperature
DONE so far (6 rungs, each run-verified): UnstablePercentage, SpeedEffect,
AnimationState, CollisionState/Normal, ReduceButton, SpeedOfTorsoHorizontal
(+ the whole Torso table), MotionState. LocalVelocity / LocalAcceleration
need nothing -- the ENGINE's Mover already publishes them (MOVER.CPP:104).
LEAF-SAFE APPENDS (do these next; each goes at the END of a class that
nothing chains off, so no id moves): ReservoirState, CondenserState,
GeneratorState (maps to the EXISTING stateAlarm), AmmoState +
FireCountdownStarted (AmmoBin has no table at all yet), LaserOn (Emitter),
TargetRangeExponent (ControlsMapper).
--------------------------------------------------------------------------------
!! ReportLeak AND ConfigureActivePress ARE NOT LEAF-SAFE -- and they reveal
the authentic attribute layout. DO NOT bulk-edit these without deciding.
--------------------------------------------------------------------------------
Both belong to BASE classes in the pinned range. The shipped string pool
gives each class's attribute names contiguously in id order:
MechSubsystem ConfigureActivePress (1)
HeatableSubsystem CurrentTemperature DegradationTemperature
FailureTemperature (3)
HeatSink NormalizedPressure DegradationPressure
CoolantCapacity CoolantMass
CoolantMassLeakRate ReportLeak (6)
PoweredSubsystem InputVoltage AuxScreenNumber AuxScreenPlacement
AuxScreenLabel EngScreenLabel (5)
Count them from Subsystem::NextAttributeID = 2: 2 | 3..5 | 6..0x0B |
0x0C..0x10 -> NextAttributeID = 0x11 -> MechWeapon needs EXACTLY ONE pad
to land its real ids on the binary-pinned 0x12. Our tree needs THREE pads,
because our tables differ: we publish HeatLoad (authentic does not),
CoolantAvailable (authentic does not), and a different PoweredSubsystem five
(OutputVoltage/RatedVoltage/VoltageState/ConnectMode instead of the four
AuxScreen* names). The one-pad arithmetic landing exactly on 0x12 is strong
evidence the four rows above ARE the original layout.
Reconciling to it is a real improvement -- it would replace three guessed
pads with the authentic table -- but it moves ids underneath the gauge
cockpit that is currently verified at 98.9% pixel-identical, so it wants a
deliberate session with the A/B rig open, not a bulk edit at the end of a
long one. Neither HeatLoad nor CoolantAvailable is bound by any gauge (0
hits in L4GAUGE.CFG), so the drop side looks cheap; the risk is the id shift,
not the names.
--------------------------------------------------------------------------------
A TRAP IN THE WATCHER WORK: publishing a name can CRASH the pod
--------------------------------------------------------------------------------
Publishing Reservoir/ReservoirState crashes the pod on the DOS extender
("Reference to a page you don't own" -> PF), and BISECTING proved it is that
one change: revert it and the run returns to a clean Fail; re-apply it alone
and the crash returns. Everything else in the same batch (Condenser,
Generator, Emitter, ControlsMapper, Torso, Myomers) is innocent and is IN.
WHY IT MATTERS BEYOND ONE ATTRIBUTE: AttributeWatcherOf<T> does
currentValue = *(T*)attributePointer; (WATCHER.HPP:306)
AT CONSTRUCTION. So a published name is READ the moment its watcher is
built -- "nothing drives the value yet, so the type cannot matter" (which is
what the earlier staging note claimed) is WRONG. A wrong-typed target is a
live crash, not a deferred cosmetic issue.
Things ruled out by measurement, so nobody repeats them:
* NOT the AlarmIndicator-vs-int type. Publishing a plain int
reservoirState instead got FURTHER (log 232 -> 749 bytes) but still
crashed, so the object copy is not the whole story.
* NOT static-init order. reservr.obj sorts LAST in the bt lib (build410.sh
puts TUs missing from the 1995 order list at the end), so
HeatSink::AttributeIndex is constructed before Reservoir's.
* NOT an id gap. ReservoirState sits at HeatSink::NextAttributeID with the
parent chain contiguous behind it.
* NOT visible on the gauge rig. The same binary runs clean under
vis_rec.conf -- only the pod/arena context faults, so reproduce it there.
The crash itself: 0044B4AD, `mov eax,[edx+0x18]` then `call [eax+4]`, with
EAX = 0x15 and the fault at 0x19. A small integer is being called through as
an object -- an index slot holding an ID where a pointer belongs. That is
the AttributeIndexSet::Build uninitialised-slot signature, so the next step
is to dump Reservoir's built index set and compare it against a class whose
publication works (Condenser is the control: same base, plain int, no crash).
SOLVED. The linker map named the faulting function outright:
tlink32 -m writes build410/btl4opt.map; the crash address minus the CODE
base (0x410000) is the map offset, and "Publics by Value" gives the
enclosing symbol. 0x3B4AD landed inside
AudioStateWatcher::AudioStateWatcher(PlugStream*,Entity*) +0x2D.
AudioStateWatcher is AudioWatcherOf<StateIndicator> and its ctor runs
Cast_Object(StateIndicator*, attributePointer)->AddAudioWatcher(this)
(AUDWTHR.CPP:891)
immediately. So every authored *State name MUST be published as a
StateIndicator (AlarmIndicator counts -- it derives from one). Point it at a
plain int and that member call goes through garbage and the pod dies on the
extender. That is why BOTH earlier attempts failed: the AlarmIndicator try
was right in type but was tested while other bugs were still in the batch,
and the "plain int" try was wrong in type and merely crashed further along.
Fixed by publishing state objects: Reservoir/ReservoirState -> reservoirAlarm,
Generator/GeneratorState -> stateAlarm, and NEW StateIndicator members for
Condenser/CondenserState and Torso/MotionState. Note StateIndicator has no
Initialize() -- it takes its count in the ctor, and the default ctor is
enough here because the watcher only needs the object to exist (the socket it
attaches to is a member). Verified on the pod: no crash, ladder advanced to
ReportLeak.
THE REUSABLE TECHNIQUE: when the pod dies with an extender fault, take the
address out of the dump, subtract 0x410000, and look it up in
btl4opt.map. It names the exact engine function -- which, for this family of
work, names the watcher class and therefore the required member type.
--------------------------------------------------------------------------------
THE WATCHER LADDER IS DONE -- and the next brick is named by the engine itself
--------------------------------------------------------------------------------
Every authored AttributeWatcher now resolves. The pod run reaches the mission,
DRAWS THE FULL COCKPIT (sensor cluster, myomers, cooling, the weapon panels,
kills/deaths) with the board booted and audio running, and exits GAME-RC=0
without a Fail. What it does NOT do is build a 3-D scene, and the engine says
why in one line:
Entity 1:1 class3035 couldn't figure out how to MakeEntityRenderables
L4VIDEO.cpp couldn't load object sky.bgf (then the whole arena:
aw02/aw03/aw04/afloor/bcor1/bdet1/bdet2/bpip1 ...)
NULL instance
VideoRenderer::MakeEntityRenderables (CODE/RP/MUNGA/VIDREND.CPP:231) is the
BOTTOM of a virtual chain -- its own comment says "when a higher level of this
virtual can't figure out how to make something it calls down to the level
below it. If we reach here then nobody could figure out what to do." Our
BTL4VideoRenderer does not override it, so every entity falls through to the
complaint, no scene graph is built, and the arena geometry that would hang off
it never loads. class3035 = 0xBDB.
CORRECTION to the earlier note in this file: the first full-rig run was
recorded as "geometry complaint count went to ZERO -- .BGF loading was never a
reconstruction problem, only a missing bridge". That reading was wrong. The
count was zero because the run Failed at an attribute watcher long BEFORE
reaching geometry. Now that the watchers all resolve, the loads are attempted
and they fail -- because nothing built the renderables to hang them on, not
because of the bridge.
SO THE NEXT BRICK IS THE REAL btl4vid BODY, and its shape is pinned by the
surviving sibling header CODE/RP/RP_L4/RPL4VID.HPP:
void MakeEntityRenderables(Entity*, ResourceDescription *model_resource,
ViewFrom type); <- override this
dpl_DCS* ReadSKLFile(Entity*, ResourceDescription*, ViewFrom,
char skeleton_type);
void RecurseSKLFile(Entity*, dpl_DCS *parentDCS, NotationFile *skeleton,
const char *page_name, int recursion_depth, ViewFrom,
int *joint_counter, dpl_LOAD_MODE cache_mode);
char SetupMaterialSubstitutionList(Entity*, ViewFrom);
void TearDownMaterialSubstitutionList();
i.e. walk the mission's entities, read each one's .SKL skeleton notation pages
(VIDEO/*.SKL are present in the mount), recurse them into a dpl_DCS hierarchy,
and load the .BGF geometry per node. Content for the BT-specific renderables
comes from the BT411 donor (game/reconstructed/btl4vid.cpp, 3188 lines:
BTReticleRenderable, BTTranslocationRenderable, the pending-wrecks map).
--------------------------------------------------------------------------------
FIRST RENDERABLE ANSWER: BTPlayer -- and how to read a NON-crashing pod run
--------------------------------------------------------------------------------
BTL4VideoRenderer now overrides MakeEntityRenderables. First answer:
class 3035 = BTPlayerClassID (VDATA.HPP: the BT block starts at
BTL4ApplicationClassID = 3000, so counting down the enum gives 3035) carries
NO GRAPHICS -- exactly what the engine already does for its own
PlayerClassID with an empty case. Everything else still chains to
DPLRenderer, so this override can only ADD answers, never remove one the
engine already gives.
Result on the pod: the "couldn't figure out how to MakeEntityRenderables"
complaint is GONE, and the run no longer exits -- it keeps running. But it
renders nothing: the wire fifodump grows at ~24 bytes/sec (1.4KB total),
which is keep-alive traffic, not a frame stream. Expected -- the mech and
the arena still have no renderables, only the player has been answered.
A PRACTICAL PROBLEM TO SOLVE FIRST NEXT TIME: a pod run that does NOT crash
produces NO readable log. The conf redirects the game's stdout
(> OUTREC.TXT) and DOS buffers it, so the file stays 0 bytes until the
process exits normally; every log this session that was readable came from a
run that CRASHED, because the fault handler flushed. Killing the pod loses
the buffer entirely.
Two ways out, both already in the toolbox:
* `echo GAME-RC=%errorlevel% >> RC.TXT` after the game line -- a separate
command, so it lands even when the game's own buffer is lost. Already
added to gauge_arena_rec.conf and it is how the clean GAME-RC=0 exit was
confirmed.
* the com3/serial3=file trick from the emulator notes -- live unbuffered
game output. Worth wiring into the pod conf before the next renderables
session, otherwise progress is invisible while things are going WELL.
NEXT: the mech (MechClassID 3001) and the arena/terrain entities are what
actually need renderables -- MakeEntityRenderables -> ReadSKLFile /
RecurseSKLFile over the .SKL skeleton pages (VIDEO/*.SKL are in the mount),
building a dpl_DCS hierarchy and loading the .BGF geometry per node.
--------------------------------------------------------------------------------
THE NO-OP WAS THE BUG: an override REPLACES the base, it does not extend it
--------------------------------------------------------------------------------
DPLRenderer::LoadMissionImplementation (L4VIDEO.CPP:6007) is NOT empty:
DPLReadEnvironment(mission); // opens L4DPLCFG (btdpl.ini), walks the
// compare/branch pages for this
// location/time and calls
// dpl_SetObjectFilePath / material /
// texmap from the objectpath= entries
LoadNameBitmaps();
The "bring-up no-op" installed earlier therefore did not do nothing -- it
SUPPRESSED the engine's own mission load, so the dPL library never learned
where the art lives and every dpl_LoadObject returned NULL. That is the whole
explanation for the 40 failed arena objects and the NULL instance.
Justifying the no-op by pointing at VideoRenderer::LoadMissionImplementation
(a bare Tell) and GaugeRenderer's (the same) was the error: neither is OUR
base. Check the ACTUAL base before overriding anything 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 -- more
evidence the authentic btl4vid chained too.)
BEFORE AFTER
40x "couldn't load object" 0
"NULL instance", run ends runs on
wire ~24 bytes/sec (keep-alive) ~12 KB/s (1.17MB and climbing)
bridge live at 88fps
PROVED IT WAS OURS, NOT THE RIG: the SHIPPED binary was run under the same
conf on the same rig and loaded every object (0 failures). That A/B is cheap
now and is the right first move whenever the pod misbehaves.
STILL BLACK. The bridge renders nothing yet and its camera sits at the
default (0,10,0) rather than the mech's eyepoint, so what is going over the
wire is state plus object loads, not a populated scene. The mech
(MechClassID 3001) and the arena entities still need MakeEntityRenderables
bodies -- ReadSKLFile / RecurseSKLFile over the .SKL pages into a dpl_DCS
hierarchy -- and the view/eyepoint flush wants checking once something is
actually in the zone.
--------------------------------------------------------------------------------
RIG: fast, observable pod runs (use this from now on)
--------------------------------------------------------------------------------
emulator/render-bridge/pod_render_rec.conf + `launch_pod.ps1 -NoSound`
* NOSOUND IS FINE ON THE SLOW CLOCK. The old note "--no-sound FREEZES
bt/rp" is half the story: it is the FAST SOS clock that needs the AWE32.
setenv.bat args are `r s n p` (RIO, SLOW clock, NOSOUND, plasma+gauges).
Saves the ~4 minute SoundFont upload AND the ~300MB of awe_*.wav taps
each run.
* LIVE UNBUFFERED LOG: serial3=file file:<path> plus `> COM3` instead of
`> OUTREC.TXT`. DOS char devices are not buffered, so the log is
readable WHILE the game runs. A plain file redirect stays 0 bytes until
the process exits normally -- which is why every readable log before this
came from a run that crashed.
* pod_render_shp.conf is the same conf running the SHIPPED exe, for the A/B.
================================================================================
THE 3-D WORLD RENDERS. Reconstruction -> emulated Division card -> a picture.
================================================================================
emulator/render-bridge/first-3d-frame.png -- the arena from the pod: sky,
horizon, ground, the arena structures along the skyline with their markings,
running at ~31fps on the VelociRender bridge.
The full chain now works end to end in OUR build: MakeVideoRenderer ->
BTL4VideoRenderer over DPLRenderer -> board boot (transputer + i860 firmware)
-> Renderer::LoadMission -> DPLReadEnvironment (art paths) -> 40/40 arena
objects loaded -> mission launch -> per-frame submission over the wire.
WHAT ACTUALLY UNBLOCKED IT, and the mistake worth remembering:
the run was NOT stalled after InitializePlayerLink. I concluded that from a
150-second sample and it was simply too short. Application::
CheckLoadMessageHandler (APP.CPP) reposts itself every second and refuses to
advance until the MIN-priority event queue drains; with the video renderer in
the mission that load takes minutes, not seconds. Traced with BT_LAUNCH_LOG:
[launch] state=11 minPriorityEmpty=0 <- loading, queue busy
... 60+ ticks ...
[launch] state=2 minPriorityEmpty=0
[launch] state=2 minPriorityEmpty=1 <- drained
BTL4Application::RunMissionMessageHandler (x2 -- the two messages that
Turning Plasma Score Display On carry WaitingForLaunch ->
BTL4Application::RunMissionMessageHandler LaunchingMission ->
Turning Plasma Score Display On RunningMission)
[tick] roster live (first Sensor frame), radarPercent=1 voltState=4
and that matches the SHIPPED binary's sequence line for line. The renderer
holds a deliberately blank screen until RunningMission
(L4VIDEO.CPP:5100, "Simple HACK to hold us on a blank screen till the mission
is actually started") -- so "black" was never a rendering bug, it was the app
still loading. WAIT FOR THE LAUNCH MARKERS before diagnosing a black pod.
STATE OF THE SCENE: no entity is unbuildable any more (zero "couldn't figure
out how to MakeEntityRenderables"), zero geometry failures, and the frame is
static only because the mech is parked with no pilot input. The camera in
the bridge title is the BRIDGE's own viewer (launch_pod: "arrows in the RENDER
window tune eye height", FP_UPOFF), not the game's eyepoint -- do not read it
as the pilot view without checking.
NEXT: drive it. With controls fed (vRIO is already running in this rig) the
mech should move and the scene should animate; then the mech's own model and
the cockpit-interior renderables are what to verify against the shipped
binary, which is a cheap A/B now (pod_render_shp.conf).
--------------------------------------------------------------------------------
FRAME RATE: measured properly, and what is actually missing from the scene
--------------------------------------------------------------------------------
The pod updates roughly once every several seconds. Measured by capturing a
head window every 2s and counting changed frames (scratchpad/headrate.py):
OURS 5 of 12 samples changed (~5s between updates)
SHIPPED 1 of 12 samples changed (~24s between updates)
So the reconstruction is NOT slower than the shipped binary here -- the
emulated Division board is simply expensive, and both run the pod at this
rate. CAVEAT on that comparison: ours was being driven (BT_FORCE_THROTTLE,
speed 14.4, gauges genuinely changing) while the shipped exe ignores those
env hooks and sat parked, so it had less to redraw. Treat the two numbers as
"same order", not as a win.
WHAT DID COST US 3x, AND IT WAS MINE: BT_MECH_LOG / BT_LAUNCH_LOG write
per-frame [sim]/[mppr] lines, and with DEBUG_STREAM=cout redirected to COM3
every one of them goes out through an emulated serial port. Turning them off
took the wire from 480 to ~1480 bytes/sec. Keep per-frame logging OFF for
anything timing-related -- pod_render_quiet.conf is that conf.
DO NOT READ WIRE BYTES/SEC AS A FRAME RATE. Shipped pushes ~8900 B/s against
our ~1480, and the difference is not speed -- it is CONTENT. Shipped is
submitting the mech; we are not:
OURS: L4VIDEO.cpp wrong video resource type for object mad.skl
SHIPPED: (no such line)
The mech's model resource is a SKELETON (mad.skl). The engine's default
MakeEntityRenderables only accepts Object/Rubble resource types and rejects
anything else with exactly that message (L4VIDEO.CPP:4265), because handling
skeletons is the GAME renderer's job -- RPL4VID.HPP's ReadSKLFile /
RecurseSKLFile. So our arena renders but the MECH IS ABSENT, and with it the
cockpit interior. That single unimplemented path explains both the missing
model and the lower wire volume.
NEXT BRICK, now precisely scoped: in BTL4VideoRenderer::MakeEntityRenderables
answer MechClassID (3001) by reading the .SKL notation pages (VIDEO/*.SKL are
in the mount) into a dpl_DCS hierarchy and hanging the per-node .BGF geometry
off it -- ReadSKLFile + RecurseSKLFile per the sibling header, content from
the BT411 donor.
================================================================================
SPEC FOR THE NEXT BRICK: the mech skeleton (.SKL -> dpl_DCS tree)
================================================================================
Everything needed to write it is now known. Written down so the next session
is mechanical rather than exploratory.
THE FILE FORMAT (VIDEO/MAD.SKL, a plain NotationFile):
[DamageZones] dz_dtorso=0 ... dz_utorso=21 (22 of them)
[ROOT] JointCount=25 DZoneCount=22
tranx/trany/tranz pitch/yaw/roll
joint=jointlocal joint=jointshadow
[jointlocal] parent=ROOT Type=balltranslate
tranx.. pitch.. joint=jointhip joint=jointlthigh ...
[jointhip] parent=jointlocal Type=hingex
Object=mad_hip.bgf <- the geometry
dzone=dz_hip <- damage-zone tags
tranx.. pitch.. joint=jointtorso site=siteedz_hip
So each page is one node: a local transform, an OPTIONAL .bgf object, zero or
more damage-zone tags, and its child joint page names. Type= is the joint
kind (ball / balltranslate / hingex / hingey) and matters for animation, not
for the initial build.
THE dPL CALLS (CODE/RP/MUNGA_L4/libDPL/dpl/DPL.H):
dpl_NewDCS() create a transform node
dpl_SetDCSMatrix(d, float32 *m) its 4x4 matrix
dpl_AddDCSToDCS(parent, child) parent it
dpl_AddDCSToScene(d) root it
dpl_SetDCSZone(d, zone) assign the zone (MakeNewZone gives one)
dpl_LoadObject(name, dpl_load_normal)
dpl_NewInstance() / dpl_SetInstanceObject(i,o) / dpl_AddInstanceToDCS(d,i)
dpl_FlushDCS(d) push it to the card
THE ALGORITHM (matches RPL4VID.HPP's ReadSKLFile + RecurseSKLFile):
ReadSKLFile: open the .SKL as a NotationFile, make the zone, recurse "ROOT".
RecurseSKLFile(page, parentDCS):
1. read tranx/y/z + pitch/yaw/roll -> a matrix -> dpl_NewDCS + SetDCSMatrix
2. parent it (AddDCSToDCS, or AddDCSToScene at the root)
3. if Object= -> dpl_LoadObject, dpl_NewInstance, SetInstanceObject,
AddInstanceToDCS (Entity_Being_Created is already set by the caller so
the C callback can tag geometry with damage zones -- see L4VIDEO.CPP:4176)
4. record dcs_array[segment index] so DPLJointToDCSTranslator (L4VIDEO.CPP:
6326) can map joint -> DCS afterwards
5. for each joint= child, recurse
THE ONE UNKNOWN: the exact dpl_MATRIX layout/convention. No surviving source
calls dpl_SetDCSMatrix -- the RP game file that did is missing, like BT's.
Derive it from dpl3-revive/spec/VELOCIRENDER_PROTOCOL.md, which decodes DCS
bodies as "type 5 (dcs), 4x4 dpl_MATRIX each", and from DPLTYPES.H.
SUCCESS CRITERION, and it is exact. The dpl3-revive reference capture of a
real pod decodes as "Transforms: flush bodies type 5 (dcs) x26" and
"Placements: type 4 (instance) x22". MAD.SKL declares JointCount=25 (+1 root
= 26) and DZoneCount=22. So a correct walk of this one file should put 26 DCS
flushes and 22 instance flushes on the wire -- countable in the fifodump
without looking at a single pixel.
--------------------------------------------------------------------------------
API INVENTORY for the skeleton walk -- all confirmed, nothing left to discover
--------------------------------------------------------------------------------
NotationFile (CODE/RP/MUNGA/NOTATION.HPP) reads every field the .SKL needs:
GetEntry(page, entry, Scalar*) tranx/trany/tranz, pitch/yaw/roll
GetEntry(page, entry, const char**) Object= (the .bgf)
MakeEntryList(page, "joint") the child pages
MakeEntryList(page, "dzone") the damage-zone tags
NameList iteration is entry = list->GetFirstEntry(); entry;
entry = entry->GetNextEntry() -- exactly how DPLReadINIPage walks
objectpath= at L4VIDEO.CPP:1858, so copy that idiom.
dPL side, all in libDPL/dpl/DPL.H:
dpl_NewDCS / dpl_SetDCSMatrix / dpl_AddDCSToDCS / dpl_AddDCSToScene
dpl_SetDCSZone / dpl_FlushDCS
dpl_LoadObject(name, dpl_load_normal) / dpl_NewInstance /
dpl_SetInstanceObject / dpl_AddInstanceToDCS / dpl_FlushInstance /
dpl_FlushObject
THE MATRIX, as far as the evidence goes. A DCS flush body decodes as
[remote:4][type_check:4][node:4][matrix:64] -- 16 float32, a 4x4 (confirmed
by dpl3-revive/patha/analyze_scene.py, which reads rest[4:68] as 16 floats).
Decoding a real BT capture (patha/bt1.raw.bin) prints a near-identity DCS with
a single 10.0 term in the LAST ROW, which points at row-major with the
translation in row 3 -- but the printed rows look shifted by one word against
a true identity, so that decoder's offset is suspect by exactly 4 bytes.
DO NOT trust this without re-deriving it; the cheap way is to build the tree
with IDENTITY matrices first (below), then set one known node's translation
and watch which slot moves in the fifodump.
RECOMMENDED ORDER OF WORK -- two stages, each independently verifiable:
STAGE 1: walk the .SKL and build the tree with IDENTITY matrices. Every
node collapses onto the origin so it looks wrong, but the STRUCTURE is
provable without pixels: the wire must carry 26 DCS flushes and 22 instance
flushes (MAD.SKL: JointCount=25 +root, DZoneCount=22 -- and the dpl3-revive
reference capture of a real pod shows exactly 26 and 22). Getting those
counts proves the recursion, the object loads and the parenting.
STAGE 2: put the real transforms in and fix the convention by observation.
ONE API STILL TO CHECK BEFORE STAGE 1: how the .SKL filename reaches us. The
engine builds a chain via L4VideoObjectWrapper::BuildVideoObjectChainFromResource
and rejects our mech with "wrong video resource type for object mad.skl"
(L4VIDEO.CPP:4265) because it only accepts L4VideoObject::Object / Rubble. So
the skeleton arrives as a different L4VideoObject::ResourceType -- find that
enumerator and branch on it in our override rather than re-deriving the name.
================================================================================
THE MECH SKELETON IS BUILT. And the intermittent crash is now THE blocker.
================================================================================
BTL4VideoRenderer now answers MechClassID: it walks the video-object chain the
way the engine does, hands every L4VideoObject::Skeleton entry to ReadSKLFile,
and recurses the .SKL into a dpl_DCS tree with the geometry hung off it.
VERIFIED, repeatedly, on the live pod:
[skl] video\mad.skl -> 26 nodes, 19 objects
no "wrong video resource type" complaint, no geometry load failures
and those counts are exactly what the file declares -- MAD.SKL has 25 "joint="
entries (+1 root = 26) and 19 "Object=" entries. Every joint page became a
DCS, every Object= became a loaded instance.
CORRECTION TO THE EARLIER SUCCESS CRITERION: this file's note previously said
the walk should produce 22 instances, reading the dpl3-revive capture's
"instance x22" against MAD.SKL's DZoneCount=22. That was wrong -- damage
zones are not geometry. There are 28 dzone= tags spread across 19 objects
(jointshakey alone carries 8 on one .bgf). The right criterion is the file's
own joint=/Object= counts, which we hit exactly.
TRANSLATIONS ARE IN, and the slot question is NOT yet settled: translation is
currently written to matrix[3]/[7]/[11] (MUNGA's AffineMatrix layout -- 3x4
row-major, translation in the 4th column, AFFNMTRX.CPP:50). It builds and
walks cleanly, but no frame has yet been seen WITH the mech, so the choice is
unconfirmed. Rotation is still identity on purpose (every base-pose
pitch/yaw/roll in MAD.SKL is 0 or ~1e-3, so translation alone assembles the
model and isolates one convention at a time).
!! A BISECT THAT LIED, worth reading before trusting any pod result !!
I concluded from single runs that non-identity translations crashed the pod,
"isolated" it by suppressing the values, and then "confirmed" the other slot
choice also crashed. All of that was NOISE. Re-running the SAME binary gave
walk-completes / crash / walk-completes / crash. The intermittent
plane-write defect ("Reference to a page you don't own", PF at 66D9 -- the
one with seven hypotheses already killed) is now firing on roughly HALF of
pod runs, and it lands at different points each time, which is exactly what
made it look like a deterministic matrix bug.
RULE FOR THIS RIG: never accept a single pod run as evidence. Run the
same binary at least twice and require agreement, or the intermittent
defect will invent a story for you.
That defect has therefore gone from background annoyance to the top of the
list: it is what stops a mech frame from ever being seen, because a run has
to survive BOTH the skeleton build AND the launch to render anything, and at
~50% per run that is a coin flip on a four-minute cycle.
--------------------------------------------------------------------------------
THE INTERMITTENT POD CRASH IS THE SKELETON WALK'S -- attributed, not yet fixed
--------------------------------------------------------------------------------
Attributed with a same-binary A/B, which is the only clean way to test an
intermittent fault: BT_NO_SKL skips the skeleton build, so one executable can
be run both ways with nothing else differing.
walk ENABLED ~50% of runs die ("Reference to a page you don't own",
PF at 66D9), at DIFFERENT points each time -- sometimes
mid-walk, sometimes after it, sometimes before launch
walk DISABLED 0 crashes in 2 runs
SHIPPED exe 0 crashes in 2 runs on the same rig and conf
So it is our new code, it is not the rig, and the varying fault point says
memory corruption surfacing later rather than a bad instruction in the walk.
HYPOTHESES KILLED so far (all by reading the private library headers, so they
stay killed):
* matrix array too small -- dpl_MATRIX is float32[4][4] (DPL_PRIV.H:137),
exactly the 16 floats we pass.
* dangling Object= name. dpl_LoadObject is handed a pointer INTO the
NotationFile, which we then delete -- but neither s_dplobject
(DPL_PRIV.H:377) nor the common dpl_node header (DPL_PRIV.H:92) keeps a
name field, so the library does not retain it there. A private name
cache is still possible and remains the best surviving suspect.
* NotationFile rewriting the game's data. ~NotationFile DOES rewrite the
file when dirtyFlag is set, which would have corrupted the shipped .SKL
files -- checked, MAD.SKL is untouched (11847 bytes, Jan 2001), and the
engine's own Verify(!IsDirty()) guard is now copied into ReadSKLFile.
STILL TO TRY, cheapest first:
1. Keep the NotationFile alive for the mission instead of deleting it (a
~12KB leak) and see if the crash rate drops -- that settles the name
cache question outright.
2. Copy each Object= name into storage that outlives the walk, same test.
3. Count what the walk allocates on the board (26 DCS + 19 objects + 19
instances + 1 zone per mech) against whatever the VPX HLE's node tables
allow -- resource exhaustion would also present as a late, varying
fault.
4. Check whether MakeEntityRenderables is invoked more than once per mech
(inside AND outside views). The [skl] summary line printed once per run
here, so this is unlikely, but it is cheap to re-check with two mechs.
BT_NO_SKL stays in the tree: it is how any future "is it the skeleton?"
question gets answered in one build instead of two.
--------------------------------------------------------------------------------
CORRECTION: THE CRASH IS NOT THE SKELETON WALK'S, AND IT IS NOT RANDOM
--------------------------------------------------------------------------------
The previous entry attributed the intermittent page fault to the skeleton walk
on the strength of "0 crashes in 2 runs" with the walk disabled. That was a
25% coin flip dressed up as evidence, and it was wrong. Two facts settle it:
* The oldest preserved crash log has NO [skl] line and ends with the old
"couldn't figure out how to MakeEntityRenderables" fallback -- it is from a
build where the walk did not exist, and it faults identically.
* Every dump carries the SAME numbers, to the byte:
Reference to a page you don't own
PF cr2= 7000 FA64 at 00FF 0000 66D9
Unhandled exception 000E at 00FF 66D9 ErrCode 0004
So "it lands at different points each time" was also wrong. What varies is
how far the log gets, not where the fault is.
READING THE FAULT. 0x66D9 is a CODE offset (_TEXT starts at 0, and the DPMI
host reports EIP relative to the code selector), so btl4opt.map names it
directly:
0x0066C0 EulerAngles::operator=(const LinearMatrix&) <- +0x19 = the fault
Disassembling it, +0x19 is `mov eax,[edi]` with edi = ebp-0x10 -- a stack local.
But the BT_STACK_LOG probe puts the frame at 0x12DEE7, and cr2 is 0x7000FA64,
1790 MB away. A stack local cannot be there, so the reported EIP is one or two
instructions past the real one and the actual fault is the preceding
`fld dword [ebx+0x20]` -- a READ through the matrix reference (ErrCode 4 = read,
page not present), with ebx = 0x7000FA44.
That is still unexplained, because a binary scan of every direct call to 0x66C0
finds exactly three call sites, and ALL THREE pass `lea eax,[ebp-0x30]` -- a
stack local. None of them can hand it a 1.79 GB pointer. Unresolved.
WHAT THE PROBE KILLED. Two clean negatives, both worth keeping killed:
* ESP drift (a calling-convention mismatch marching the stack down a few
bytes per call). The probe reports drift=0 over 2002 frames, and a scan of
the whole binary finds exactly ONE callee-cleans function (__virt_decommit,
a runtime helper) -- everything else is cdecl, so there is nothing to
mismatch.
* A stack too small for our deeper call chains. Our PE and the shipped one
have identical geometry: 1 MB reserve, 8 KB commit.
WHAT THE PROBE FOUND INSTEAD -- and this matters more than the crash. The
application is parked in state=2, which is LoadingMission, NOT WaitingForLaunch.
It never advances, because the gate needs IsPriorityEmpty(MinEventPriority) and
priority 0 is where InterestManager::PostRendererEvent queues every renderer
event while a mission loads. The queue never drains, the mission never
launches, and the video renderer holds a blank screen by design until it does.
The crash arrives after ~30 seconds of that. Runs that DO launch never print a
single [launch] line -- they sail through the gate -- so "crashes about half the
time" and "hangs during load" are the same event, seen twice.
A TIMING RESULT THAT LOOKS LIKE A BUG AND IS NOT. The gate reposts itself at
Now()+1.0s and printed 31 times while the stack probe -- which accumulates
frame_duration to 1.0 -- printed twice. A 15x disagreement between two clocks.
It traces to `new ApplicationManager(GetTicksPerSecond())` in BTL4.CPP, which
passes a TICK rate (1000, from TIMESTUB.CPP) where the parameter is a FRAME
rate, making frameDuration 1ms. That reads exactly like a reconstruction slip,
so it was checked against the shipped binary before touching anything -- and
the shipped call site is the same instruction sequence pushing the same kind of
static float:
ours 6a 28 call new 59 8b d8 85 db 74 11 ff 35 a0 11 4e 00 53 call ctor
shipped 6a 28 call new 59 8b d8 85 c0 74 11 ff 35 0c 14 52 00 53 call ctor
So it is authentic. The 15x is just this emulated rig failing to hit a 1000 Hz
target, which the original hardware would have missed too. DO NOT "fix" it.
WHERE THIS GOES NEXT. The open question is why priority 0 drains for the
shipped exe and not for ours. Application::ProcessOneEvent now counts calls and
hits (BT_STACK_LOG reports them), which separates the three candidates in one
run: the pump never runs, the pump runs but finds nothing, or the pump drains
events as fast as something re-posts them.
TOOLING BANKED. emulator/render-bridge/podrun.sh stages the build over
BTL4REC.EXE, exports the host-side VPX board env (without which the run dies at
the iserver handshake, not merely unrendered), and archives each run's log --
marking the file -CRASH when it faulted. The only preserved dump before this
was an accident, on a rig where every run costs four minutes.
--------------------------------------------------------------------------------
THE LOAD GATE: WHAT THE EVENT-PUMP COUNTERS SHOWED
--------------------------------------------------------------------------------
Two runs of the instrumented build, both ending in the same fault:
[stack] ... over 1001 frames ticks=24729 pump=352/1499
[stack] ... over 2002 frames ticks=41586 pump=495/2643
[launch] state=2 minPriorityEmpty=0 ... (31 reposts, then the fault)
Reading those numbers rules out most of what "the queue never drains" could
mean:
* The pump RUNS. 1144 ProcessOneEvent calls over 1001 frames is 1.14 per
frame -- one from the HighEventPriority check at the top of
ExecuteBackgroundTask, plus one every seventh frame from
BackgroundTasks::Execute, which round-robins ONE task per call across our
seven tasks. That 1-in-7 is exactly the rotation, so the early-return path
is NOT starving the background list.
* The pump FINDS WORK. 143 hits per 1001 frames is ~1 per 7 -- essentially
every ProcessEventTask call succeeds, while the HighEventPriority calls
find nothing. So events are being processed continuously, at about 8 per
real second, and priority 0 still never empties after 30+ seconds.
* events=0 in the trace is a red herring: ++eventCount lives inside
#if defined(TRACE_EVENT_COUNT), which this build does not define, so
GetEventCount() reads 0 no matter what is queued. The probe now prints
per-priority occupancy instead, which needs no counter.
That leaves two candidates, and they want opposite fixes:
1. VOLUME. There really are hundreds of priority-0 renderer events and we
drain them at 8/sec, so the load simply has not finished when the fault
arrives. Our MakeEntityRenderables loads 19 .bgf objects per mech through
the EMULATED board, which is not a cheap operation.
2. DEADLOCK. Priority 0's head is a TIMED event whose alarm never comes due.
PeekAtNextEvent only returns a timed event when alarmTime <= Now(), but
IsPriorityEmpty counts it regardless -- so such an event is invisible to
the pump and permanently visible to the gate.
The [queues] probe prints every priority's occupancy each second, which
separates them: only p0 busy means the deadlock, higher priorities busy means
we are just watching a long load.
RULED OUT ALONG THE WAY. The queue is allocated correctly
(GeneralEventQueue::Make does new GeneralEventQueue[5], EVENT_PRIORITIES_COUNT
is 5, MaxEventPriority is 4, so this[priority] indexing is in range).
Application::Post passes its priority through untouched. The renderer's
NotifyOfNewInterestingEntity handler consumes its message without re-posting,
so there is no refill loop from that path.
A SEPARATE THREAD WORTH PULLING. The emulator log -- newly captured by
podrun.sh -- shows a steady trickle of serial1 RX OVERRUN errors on the RIO
named pipe, and ControlsInstanceEventOf<T> posts at HighEventPriority
UNCONDITIONALLY (CONTROLS.HPP:250), with no application-state guard. Our log
also carries "RIO never came back from test mode!" where the shipped binary
does not. pod_render_norio.conf disables serial1 and changes nothing else, so
a launch there against a hang here isolates the RIO in one run.
--------------------------------------------------------------------------------
IT IS A REFILL LOOP, AND IT IS DETERMINISTIC
--------------------------------------------------------------------------------
Per-priority occupancy, steady state, every second until the fault:
[queues] p0=BUSY p1=- p2=BUSY p3=- p4=- nextReady=1
* p3 and p4 EMPTY kills the starvation theory outright. Nothing above
priority 0 is competing, so the pump is free to serve it.
* p2 busy is this handler's own once-a-second repost. Expected.
* nextReady=1 means PeekAtNextEvent always has a READY event to hand out --
so priority 0 is not holding a timed event that never comes due either.
That kills the deadlock theory.
What is left is the only remaining shape: priority 0 is being REFILLED as fast
as the pump drains it, at ~143 events per simulated second.
And it is 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 this was
never a race, and "crashes about half the time" was always an artifact of
comparing runs that differed in binary or conf, not in luck.
Only InterestManager::PostRendererEvent posts at priority 0 -- it maps every
renderer event there while the application is not RunningMission -- so naming
the message names the flooder. Application::Post now tallies priority-0 posts
by message ID and the [posts] line reports the busiest three. The IDs to
expect, from RENDERER.HPP against Receiver::NextMessageID = 3:
3 NotifyOfNewInterestingEntity 6 StopAllEntityEffects
4 NotifyOfBecomingUninteresting 7 StartEntityAlarm
5 StartEntityEffect 8 StopEntityAlarm
3 and 4 climbing together would mean entities are oscillating in and out of the
interest set; 3 alone means something re-adds them; 5 or 7 would point at
effects or alarms instead.
The controlled A/B to run alongside it: pod_render_noskl.conf now carries the
same probes, so the SAME instrumented binary can be run with the skeleton walk
on and off. Earlier walk-off runs reached "Turning Plasma Score Display On",
which is the only configuration of ours known to launch -- if walk-off also
stops the refill, the walk is implicated in the hang even though it is NOT the
cause of the fault signature (which predates it).
--------------------------------------------------------------------------------
SOLVED: THE RIO SERIAL PORT WAS BLOCKING EVERY MISSION -- AND A MISSION NOW RUNS
--------------------------------------------------------------------------------
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, same
everything else. Result:
[launch] state=2 minPriorityEmpty=0 ticks=84205
[launch] state=2 minPriorityEmpty=1 ticks=85316 <- backlog finally empty
[queues] p0=- p1=- p2=BUSY p3=- p4=- <- priority 0 drained
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
The mission LAUNCHES and the mech WALKS under the forced throttle/turn. No
fault, through 290 log lines and counting.
WHAT THE HANG ACTUALLY WAS, after three wrong answers. Not starvation, not a
timed-event deadlock, not a refill loop -- it was the most mundane candidate on
the original list, VOLUME, which I talked myself out of:
* 530 renderer events queue at priority 0 while a mission loads.
* BackgroundTasks::Execute runs exactly ONE task per call, round-robin over
our seven tasks, and the 1ms frame budget is always blown on this rig so no
extra background passes happen. That is ~9 events drained per REAL second.
* So a full load legitimately takes ~60 seconds here, and the gate correctly
stays shut for all of it.
The fault arrived at ~40 seconds -- BEFORE the backlog could clear -- which is
why the load "never" finished. Remove the fault and the same 530 events drain
to completion and the gate opens. Every "the queue never drains" conclusion
was really "the process dies before it can".
The tell I misread: p0=BUSY with nextReady=1 and a working pump looks like
refill, but it is equally consistent with a large finite backlog still
draining. The tally settled it -- id3 climbed to 530 and then went FLAT, which
means nothing was refilling. I had the disproof in hand a run earlier than I
acted on it.
WHY THE RIO PORT. The conf points serial1 at a vrio named pipe with no server
attached, which is supposed to behave as an unplugged cable. The emulator log
(now captured by podrun.sh) shows a steady trickle of serial1 RX OVERRUN errors
on it, and our build prints "RIO never came back from test mode!" where the
SHIPPED binary prints "LBE4ControlsManager::Execute, lost RIO analog request"
and keeps going -- shipped launches and runs the mission on this identical rig.
So the unplugged port is survivable and our handling of it is not. That is the
next thread, and it is a real defect rather than a rig artifact: a pod whose
RIO cable is unplugged should still boot.
OPERATIONAL CONSEQUENCE. Until that is fixed, dev-rig runs that do not need
the RIO should use pod_render_norio.conf. It is the only configuration in
which our build reaches a running mission, which unblocks everything
downstream: seeing a mech frame at all, confirming the translation slot, and
adding rotation.
STILL OPEN. The wild reference at EulerAngles::operator=(const LinearMatrix&)
+0x19 remains unexplained -- cr2 is 1.79GB from a stack whose frame pointer the
probe shows stable, and all three call sites of that operator pass stack
locals. It is now reproducible on demand (enable serial1) rather than
intermittent, which makes it a tractable hunt instead of a coin flip.
--------------------------------------------------------------------------------
FIRST RENDERED FRAME FROM A RUNNING RECONSTRUCTED MISSION
--------------------------------------------------------------------------------
emulator/render-bridge/first_mission_frame.png -- the arena city, textured
buildings, horizon, 28fps, captured from the GL bridge while our build ran a
live mission with the mech walking under forced throttle. The skeleton went in
with it: [skl] video\mad.skl -> 26 nodes, 19 objects.
CORRECTION TO THE RIO FINDING. I wrote that serial1 pointed at a pipe with no
server and called it an unplugged cable. That is wrong twice over:
* VRio.App is RUNNING on this rig (vRIO v2026.07.16), so the pipe has a
server -- the dev-rig default since 7/17.
* The emulator log recorded real byte counts on that port (RX overruns of
471, 503, 528). An unconnected pipe delivers nothing, so the port was
live and streaming during the crashing runs.
So the defect is NOT "we mishandle a disconnected port". It is "we mishandle a
LIVE RIO stream", which is worse: every production pod has a real RIO. The
shipped binary on the same rig prints "LBE4ControlsManager::Execute, lost RIO
analog request", keeps sending characters (18848 by the time it was killed),
and runs the mission. Ours prints "RIO never came back from test mode!" and
then dies partway through the load. The RX OVERRUN counts say the guest is not
draining the port fast enough, which is the thread to pull: L4RIO.CPP's
init handshake is authentic archive code, but MechRIOMapper (BT/MECHMPPR.CPP)
is ours.
pod_render_norio.conf remains the way to get a running mission today, but it is
a WORKAROUND, not the fix -- it disables the cockpit's primary input device.
THE CAMERA DOES NOT MOVE, AND OUR OWN CODE EXPLAINS IT. 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), and the bridge title reads
cam (0.0, 10.0, 0.0) throughout. The scene renders; the eyepoint is never
driven.
That is our MakeEntityRenderables doing exactly what it was written to do. When
ReadSKLFile finds a Skeleton entry it sets handled = True, and the chain to
DPLRenderer::MakeEntityRenderables is then SKIPPED -- but that is the call that
builds the RootRenderable and the DPLEyeRenderable which the camera follows
(L4VIDEO.CPP:4548-4566). So we hand the board a skeleton with no root and no
eye.
Which closes the loop on the fault, too: the eyepoint construction we are
skipping is the same code that faults when it does run. The mech case has to
end up doing BOTH -- the engine's root/eye renderables AND our skeleton -- so
the EulerAngles::operator=(const LinearMatrix&) fault has to be understood
before the mech can be seen from its own cockpit. It is now reproducible on
demand rather than intermittent, which is the difference between a hunt and a
coin flip.
--------------------------------------------------------------------------------
CORRECTION: THE FAULT ADDRESS WAS NEVER IN OUR CODE
--------------------------------------------------------------------------------
Every conclusion in this file that named EulerAngles::operator=(const
LinearMatrix&) as the fault site was wrong, including the disassembly arguments
built on top of it. Three independent proofs:
1. THE PROBE PRINTED NOTHING. 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 call with a wild pointer. The run faulted
with ZERO [euler] lines. The function was never called at all.
2. THE INSTRUCTION AT THE FAULT ADDRESS CANNOT FAULT. After the probe was
added, 0x66D9 disassembles to `jnl 0x66f8` -- a two-byte conditional jump
that touches no memory. The dump reports ErrCode 0004, a data READ.
3. THE ADDRESS NEVER MOVES. 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 (it was `mov eax,[edi]` before). A real
fault in our CODE segment moves when the code moves. This one does not,
which means it lives in something that does not get relinked: the DPMI
host (32rtm / DPMI32VM.OVL), which loads low.
The map lookup that started it all was a COINCIDENCE. Our _TEXT is
section-relative from 0, so its offsets overlap the host's low addresses, and
btl4opt.map will cheerfully name a function for any small number handed to it.
The lesson is specific and worth keeping: before resolving a fault address
against the map, confirm the address actually BELONGS to our segment -- the
cheapest check being whether it moves when the binary is relinked.
WHAT IS ACTUALLY TRUE, by same-binary A/B this morning:
vRIO process DOWN, serial1 still a namedpipe -> mission LAUNCHES
vRIO process UP, same conf, same binary -> FAULT
So the trigger is a LIVE RIO STREAM, not the port's existence and not an
unplugged cable. Supporting evidence: the emulator reports continuous serial1
RX OVERRUN errors (the guest is not draining the port), the fault lands at
whatever the app happened to be doing (terrain on one run, the mech's inside
view on another), and the SHIPPED binary survives the identical conditions --
it logs "lost RIO analog request", keeps sending characters, and runs.
A fault at a fixed host address, landing at arbitrary points in the
application, that requires an interrupt-driven data stream, is an INTERRUPT
RE-ENTRANCY signature, not a wild pointer in our data. That is where the hunt
goes next: the RIO ISR path and what it re-enters. L4RIO.CPP is authentic
archive code; MechRIOMapper (BT/MECHMPPR.CPP) and the controls plumbing are
ours.
REPRODUCING IT: start VRio.App, run pod_render_rec.conf. Stop vRIO (or use
pod_render_norio.conf) for a clean mission.
--------------------------------------------------------------------------------
THE MECH NOW GETS BOTH SKELETON AND ENGINE RENDERABLES -- CAMERA STILL FROZEN
--------------------------------------------------------------------------------
BTL4VideoRenderer::MakeEntityRenderables no longer stops after the skeleton.
It builds ours AND chains DPLRenderer, which is what creates the
RootRenderable and the DPLEyeRenderable. 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 for its own sake: the
engine's eyepoint construction -- the prime suspect for the page fault through
most of a session -- runs perfectly here, and the stack at that point is
0x12dde7, right beside the main loop's frame. Both facts are consistent with
the corrected finding that the fault is a live-RIO artifact at a fixed DPMI
host address.
BUT THE CAMERA STILL DOES NOT MOVE. Two frames six seconds apart differ by
ZERO pixels while [sim] reports the mech travelling from (487,20,288) to
(493,20,301), and the bridge title reads cam (0.0, 10.0, 0.0) throughout --
the world origin plus eye height.
So building the eye renderable was necessary but not sufficient, and the gap is
downstream. What has been ELIMINATED, by reading our own code rather than
guessing:
* The viewpoint entity IS the mech. BTL4Application::MakeViewpointEntity's
MechClassID branch does viewing_entity = Mech::Make(...) (BTL4APP.CPP:354).
* The video renderer IS linked to it. MakeAndLinkViewpointEntity calls
videoRenderer->LinkToEntity(viewpointEntity) (APP.CPP:1337).
* SetupCull would Fail() loudly if a JointedMover lacked "siteeyepoint" or
the EyepointRotation attribute. Neither Fail appears, so the mech takes
the JointedMover branch and both are present.
That leaves the path from SetupCull's worldToEyeMatrix to what the bridge
actually reads off the wire. cam (0.0, 10.0, 0.0) looks like the BRIDGE's own
default eye height (FP_UPOFF) never being displaced by a view from the game,
which points at the eyepoint compose in live_bridge rather than at the
reconstruction. That is its own sub-project -- see COCKPIT-CAGE-NOTES.md,
where the eyepoint/glance compose is already documented territory.
--------------------------------------------------------------------------------
THE CAMERA GAP, ROOT-CAUSED END TO END (three layers deep)
--------------------------------------------------------------------------------
LAYER 1 -- our code. "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" (it was there
in the log, line 344, both chain runs) and built NOTHING. No RootRenderable,
no eye, no articulation -- and the fifodump proved it: ZERO vr_flush_dcs_artic
(0x1f) records against 6667 one-shot flushes. The bridge decoder was fine;
the game genuinely never articulated anything.
The real fix mirrors the engine's own Mover composition (L4VIDEO.CPP:
4795-4860) inside OUR mech case, because the base cannot do it for skeletons:
RootRenderable(entity, Dynamic, NULL, dplMainZone, ...) <- per-frame flush
ReadSKLFile(entity, this_root->GetDCS(), ...) <- skeleton UNDER it
if inside: DPLEyeRenderable(..., root_DCS, dplMainView,
GetAttributePointer("EyepointRotation")) <- the camera
ReadSKLFile grew a parent_dcs parameter for this. NULL keeps the old
add-to-scene behaviour. Also explains the mystery monolith in the first
frame: the skeleton was parked AT THE WORLD ORIGIN with the camera at
origin+eye-height -- we were looking at our own mech from inside its shins.
RESULT: anim_abs went 0 -> 1 on the bridge (first articulation our build has
ever put on the wire), the bridge camera left the origin -- cam (54.2, 20.0,
-662.5), tracking the mech's neighbourhood at its correct Y -- and the bridge
even flagged all 19 own-mech geometry instances as drawn around the eye.
LAYER 2 -- the 1995 library, read from our own linked binary (the map is
trustworthy here because these are real linked symbols, not a stray fault
address): dpl_DrawSceneComplete = velocirender_frameack(0). frameack keeps a
"frame acked" flag; when a reply arrives whose action is NOT 9 it prints
"dpl error - unsolicited input during frame ack", adds a puzzled
"flush artic??" when that stray action is 0x1f -- the 1995 authors' own
annotation -- and calls exit(9). Worth knowing before it ever fires.
LAYER 3 -- the EMULATOR, where the actual deadlock lived. With per-frame
articulation flowing for the first time, the game stopped drawing at frame 232:
one 0x1f burst per frame forever after, no receives, no draws, no error, sim
fine. The VPX device feeds the frame ack only after POLL_THRESHOLD=6
CONSECUTIVE empty inputStatus polls, and vpxlog.cpp:669 reset that counter on
EVERY outputData write ("a write means the game isn't blocking-reading").
Every frame now writes an articulation burst, the game polls once or twice a
frame, the counter never reaches 6, dpl_DrawSceneComplete never goes true.
The heuristic was calibrated on traffic that had no per-frame writes; ours is
the first game state that both articulates and draws through this device.
Fix (vpx-device/vpxlog.cpp, synced to src/src/hardware): the reset is now
gated on !frame_outstanding -- once a draw_scene is outstanding, the only
receive the game will do next is the frame ack, so intervening writes must not
cancel the poll accumulation. The iserver-drain concern the reset guarded is
a boot-time state where no frame is ever outstanding.
STATUS: emulator rebuilding; verification = the same norio run drawing PAST
frame 232 with the camera tracking, then two agreeing runs before belief.
--------------------------------------------------------------------------------
THE MECH STANDS: MATRIX CONVENTION PROVEN FROM ENGINE SOURCE, CONFIRMED VISUALLY
--------------------------------------------------------------------------------
emulator/render-bridge/pose_chase.png -- the MadCat assembled from its 19
parts, standing in the arena at its live articulated position (chase render of
the live fifodump, framed on anim_abs root 0x672). Feet, legs, torso, weapon
pods, canopy plate with the purple glass panels. Not a collapsed stack.
THE CONVENTION, now read from the engine rather than inferred from captures:
RootRenderable's ctor writes an entity pose into a DCS via
*(Matrix4x4*)dpl_GetDCSMatrix(myDCS) = myEntity->localToWorld;
so Matrix4x4::operator=(const AffineMatrix&) (MATRIX.CPP:130) IS the wire
layout: Matrix4x4 is ROW-major (entries[(Row<<2)+Column], MATRIX.HPP), rotation
in rows 0-2, ZEROS in column 3, TRANSLATION IN ROW 3 = entries 12/13/14.
The previous choice (translation at 3/7/11) rested on two errors, both now
corrected in RecurseSKLFile's comments: (1) AffineMatrix DOES keep translation
at entries 3/7/11 -- but because it is COLUMN-major (entries[(column<<2)+row],
AFFNMTRX.HPP:99), that is its (3,c) translation ROW by storage; Matrix4x4
transposes on copy. Citing it as precedent for a row-major layout was reading
the storage and ignoring the indexer. (2) "translation in 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, so the rotation order is the
engine's by construction. Node write uses the RootRenderable idiom
(dpl_GetDCSMatrix + assign), not dpl_SetDCSMatrix.
Tooling: the chase-frame variant of fifobridge (camera framed on the
articulated root instead of arena bounds) is the ten-line proof harness for
any future pose question -- offline, against a copy of the live dump, zero
interference with the running pod.
REMAINING VISUAL WORK, in order of blockingness:
* Eye height: the inside view sits at hull Y+UPOFF and stares through the
torso panels. The engine's answer is the siteeyepoint segment compose
(SetupCull) -- our skeleton has the joint chain, so the eye DCS should ride
jointcockpit/siteeyepoint rather than the root. COCKPIT-CAGE-NOTES.md
territory (glance-hide / punch cutout) applies once the height is right.
* Proportions/orientation fine-tune once a cleaner outside frame exists;
limbs may need per-joint rotation signs checked against a shipped capture.
--------------------------------------------------------------------------------
THE LIVE-RIO FAULT: THREE ELIMINATIONS AND ONE NEW DEFECT
--------------------------------------------------------------------------------
Same-day follow-ups on the RIO fault (cr2 7000FA64 / EIP 66D9, DPMI-host
address), all on the ack-fixed emulator:
* NOT the ack heuristic: the fault reproduces identically on the fixed
emulator (vRIO up -> fault, one run).
* NOT the debug logging: pod_render_quiet.conf (no BT_* env, minimal COM3
traffic) faults the same. The COM3-write-holds-off-the-ISR theory is
dead.
* NOT the failed test-mode handshake: the quiet run's init PASSED (zero
"RIO never came back" lines) and it faulted anyway. Init failure and the
fault are fellow travellers, not cause and effect.
The Thrustmaster isolator (pod_render_tm.conf: L4CONTROLS=THRUSTMASTER so the
game never opens the RIO while vRIO still streams into serial1) was blocked by
its own discovery: TM mode dies at boot in
ControlsInstanceDirectOf<int>::Update(void*)+0x6 -- mov eax,[eax+0x1c] with
EAX=0, guest address 00485E1A, which resolves CLEANLY in our map and is a REAL
defect in our controls wiring for the non-RIO path. (Applying the new rule:
this one is genuinely ours -- the module/offset line names BTL4REC.EXE CODE
0x75E1A directly.) So TM mode is its own reconstruction brick, and the RIO
isolation experiment needs it fixed first.
Facts that survive for the next session: OUR exe + live vRIO stream = fault in
~30-40s; SHIPPED exe + the same stream on the same emulator = runs fine (two
baselines). Our binary differs in the per-event path the RIO stream drives:
ISR and LBE4ControlsManager are archive code, but the mapper (BT/MECHMPPR.CPP)
and the entire event/receiver plumbing (MUNGA/APP.CPP, RECEIVER.CPP) are ours.
RIO_TAP produced no file on this path -- byte-level dialogue comparison needs
a different tap point.
--------------------------------------------------------------------------------
CORRECTION FROM THE OPERATOR: pose_chase.png WAS UPSIDE DOWN -- HARNESS, NOT WIRE
--------------------------------------------------------------------------------
The operator caught it immediately ("-y is up here"): the 5.3.65 milestone
image showed the mech hanging from its own ground-shadow plate. The follow-up
established exactly what was and wasn't wrong, and the reconstruction survives
untouched:
WHAT IS RIGHT (three independent proofs, wire-level):
* MAD.SKL offsets are a coherent Y-UP model: hip +5.29 above ground, thigh
+0.24, knee -1.97 and ankle -2.39 descending, torso/missile mounts/eye
ascending. Anatomically consistent ONLY read +Y-up.
* The BGF vertex extents agree with the offsets: foot meshes extend -1.0
below their ankle joint (the sole), toe meshes -0.65 below their joints,
the torso mesh +3.8 above shakey (to the missile-pod tops), thigh meshes
reach down past the knee joint. One space, both data families.
* THE SHIPPED POD'S WIRE CARRIES THE SAME SIGNS. bt4.raw.bin's mech
skeleton: hip node (0,+6.21,0), knees negative, paired thighs +/-1.71,
and the vehicle root (df1) is a PURE YAW (+1 on the Y diagonal, det +1) at
its world position -- no flip anywhere between game and board, in either
game. Our wire and shipped's are convention-identical.
WHAT WAS WRONG: the ad-hoc chase camera in the 5.3.65 render. It borrowed
fifobridge's aerial up-hint ([0,-1,0], correct for the calibrated LIVE-bridge
world where -y is up) and applied it to a raw scene-space render of Y-up wire
data -- screen-up landed on model-down, mech hangs from its shadow, and the
symmetric checkerboard ground disguised it. The shipped capture rendered
upside-down through the same harness, which is what cleared the wire.
THE TWO CONVENTIONS, so nobody trips on this again:
* WIRE / scene space: Y-UP. Both games, models and skeletons alike.
* LIVE bridge presentation: -y is up. The calibrated path (fp_cam, the
mirrored det -1 root basis, FP_RIGHT_SIGN / CAGE_TWIST_SIGN family) maps
between them; COCKPIT-CAGE-NOTES.md documents that machinery.
TOOLING: pose_probe.py replaces the throwaway harness -- updir=+1 default
(scene space), updir=-1 reproduces the bridge-world sense. Verified:
pose_chase_upfixed.png shows the MadCat right side up -- chicken-walker legs,
purple knee actuators, feet planted, shadow quad UNDER the feet, weapon-pod
arms. The milestone stands, now with the correct picture.
--------------------------------------------------------------------------------
THE LIVE-RIO FAULT AND THE TM BOOT CRASH WERE ONE DEFECT: THE TORSO TABLE
--------------------------------------------------------------------------------
The [map] audit (BT_MAP_LOG in BTL4APP.CPP, printed just before the archive's
CreateStreamedMappings consumes the streamed table) named it in one run:
[map] BAD direct: idx=1 group=3 elem=68 subsysID=17 attrID=12 (attr NULL)
[map] BAD direct: idx=2 group=3 elem=67 subsysID=17 attrID=13 (attr NULL)
Subsystem 17 = Torso (the [roster] dump). The donor's decompiled torso.hpp
carries the authentic enum WITH binary offsets: ids 3..15 =
RotationOfTorsoVertical..SpeedOfTorsoHorizontal (3-8), then StickPosition(9),
TorsoUp(10), TorsoDown(11), TorsoLeft(12), TorsoRight(13), TorsoCenter(14),
MotionState(15). Our table had SEVEN entries with MotionState at id 9, on
the strength of a comment that said "TorsoUp/Down/etc are MESSAGES, not
attributes". Wrong -- and wrong in a specifically nasty way, because the
streamed control mappings bind BY ID as DIRECT WRITE DESTINATIONS
(L4CTRL.CPP:1936 registers GetAttributePointer(attributeID) as the pointer a
device poll writes through):
* TM mode: button ids 12/13 resolved NULL -> the boot write-fault at
ControlsInstanceDirectOf<int>::Update+0xE the moment the PC joystick
polled. Instant, clean, easy.
* RIO mode: analog id 9 (StickPosition) resolved to our motionState
STATEINDICATOR -- so every analog packet from a live vRIO wrote raw
floats over a watcher-socketed object. Corrupted socket chains walked
into unmapped memory ~30-40s later: the "intermittent" pod fault at the
fixed DPMI-host address, the one that ate two days. vRIO down = no
analog stream = no corruption = mission runs. Every observation from
the whole hunt drops out of this one mechanism.
FIX: Torso publishes the full 13-entry donor table; the five int command
members are carved from the front of the dynamicsState reserve (class size
unchanged); ctor zeros them. SpeedOf* still point at base rates until
TorsoSimulation brings the live velocity accumulators.
VERIFIED, two agreeing runs each way:
* TM mode: launches, [map] audit clean, and the Thrustmaster joystick
DRIVES the torso -- stickY=0.907 -> torsoElev/eyePitch=0.349. The
non-RIO controls path lives.
* RIO mode with vRIO STREAMING: launches, zero faults, mission runs deep
enough that weapons cycle. pod_render_rec is no longer poisoned; the
norio workaround is obsolete.
Incidentally the [euler] probe now prints during missions with sane
stack-local pointers -- EulerAngles::operator=(const LinearMatrix&) was
always innocent, now visibly so.
METHOD NOTE for the file: the audit-before-the-archive-call pattern (walk the
same streamed table our code hands to archive code, name what will not
resolve) turned a two-day intermittent heap-corruption hunt into a one-run
lookup. The streamed RES tables are BINDING CONTRACTS on our attribute
enums; every subsystem the RES maps should get the same audit treatment.
--------------------------------------------------------------------------------
THE COCKPIT EYE SITS IN THE CANOPY -- SITE HANDLING IN THE SKELETON WALK
--------------------------------------------------------------------------------
The eye no longer rides the hull root. RecurseSKLFile now walks "site="
children (sites get NO draw component -- which is WHY the walk's 26 nodes
matched the real capture: 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 (siteeyepoint = identity)
parent = the site's PARENT joint's DCS (jointeye, NOT the root)
so world orientation, torso twist and gait all arrive through chain
composition, with no per-mech forward-axis assumption. The old root-DCS eye
remains only as the zero-construction fallback for a skeleton with no
siteeyepoint (the engine's own default, L4VIDEO.CPP:4849).
MEASURED on the live pod (RIO streaming, post-5.3.68): [eye] cockpit eye on
'jointeye', [skl] 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 = 7.66. canopy_eye1.png: the pilot's view, with the MadCat's
own nose wedge visible below the sightline -- own-mech cage handling
(COCKPIT-CAGE-NOTES.md punch cutouts) is the known next layer.
--------------------------------------------------------------------------------
THE FULL DIRECT-MAPPING CONTRACT, AUDITED -- AND THE FAULT IS NOT FULLY DEAD
--------------------------------------------------------------------------------
The canopy-eye confirming run (post-5.3.68 build #5 with vRIO live) FAULTED
with the old signature at ticks 53195, during the load drain. Four clean runs
then one fault: the Torso fix removed the dominant corruption source but not
all of it, or something else entirely is still alive. The two-runs rule keeps
earning its keep.
So the audit got extended from "flag NULLs" to "print the whole resolved
contract". The L4 (RIO) list carries 13 DIRECT mappings (the other 96 are
event mappings, which dispatch through handler tables rather than raw pointer
writes):
mapper (sub=0, cls=2012): id 3 joystick -> stickPosition (8-byte write)
id 4 scalar -> throttlePosition
id 6 button -> reverseThrust
id 10/11/12 btn -> lookLeft/lookRight/lookBehind
Torso (sub=17, cls=3013): id 14 button -> torsoCenter (donor: 14 = TorsoCenter, matches)
6 weapons (cls 3016/3021/3024): id 19 btn -> TriggerState (the known 0x13 binding)
Every destination resolves, every SIZE is right (ControlsJoystick =
Vector2DOf<Scalar> = 8 bytes lands in an 8-byte member; buttons are ints into
ControlsButton ints). One TYPE wart, noted not fixed: MechWeapon binds
TriggerState onto fireImpulse, which is a Scalar -- a ButtonGroup direct
writes an INT bit-pattern there, so a real trigger press stores 1 as
0x00000001 = 1.4e-45f. Size-safe (no corruption) but the fire FSM reading it
as a float threshold will never see a real button press as a pull -- the RIO
trigger is likely non-functional until TriggerState gets an int home. (Fire
worked in tests via the BT_FORCE_FIRE hook, which writes the member as a
proper float -- which is exactly why this never showed.)
With the direct contract clean, the surviving fault theories are:
1. A second corruption source OUTSIDE the direct mappings (event-mapping
payload mis-typing; a watcher writing through a stale pointer).
2. Not corruption at all: an emulator/DPMI-host interaction in serial IRQ
delivery whose probability the Torso fix merely CHANGED by changing
guest timing. The fault's constants -- same host EIP, same cr2, always
during the load drain -- fit this better than they ever fit the
corruption story, where the smash point should move with our data
layout (it never did, across builds where our BSS moved).
3. Sampling noise on a genuinely rarer residual of theory 1.
A 4-run streak (riostreak.sh) is measuring the post-fix rate now. If the
rate holds nonzero, the next probe is EMULATOR-side: hook the fork's paging
on cr2==0x7000FA64 and dump the last serial-IRQ deliveries + guest cs:ip
history -- the AWE debug kit pattern, pointed at the UART.
CORRECTION to the TriggerState wart above, same day: there is no wart. Our
CheckFireEdge (MECHWEAP.CPP:275) already compares fireImpulse's BIT PATTERN as
a signed int -- sign-correct for the button ints the direct mapping bit-copies
in AND for genuine float values, reproducing the binary's x87 behavior at
004b9608. The donor types the member Scalar exactly as we do, and the binary
itself binds TriggerState to fireImpulse. The RIO trigger path is sound as
built; the 5.3.70 commit message's claim that a real press could never fire
was wrong -- written before reading our own edge detector.
--------------------------------------------------------------------------------
POST-FIX FAULT RATE MEASURED; TORSO BUTTON COMMANDS WIRED INTO THE SIM
--------------------------------------------------------------------------------
riostreak.sh, four consecutive vRIO-live runs: CLEAN(88228) CLEAN(93313)
FAULT(42274) CLEAN(92822). Combined post-Torso-fix tally: 8 runs, 2 faults,
~25%%, and the pattern is rigid -- every fault lands in the LOAD WINDOW
(ticks 42-53k), every clean run survives to launch (88k+) and then runs
indefinitely. Pre-fix the rate was 3/3.
The reframe that matters: our load window is ~60s of slow event drain;
shipped's is roughly a quarter of that. If the residual fault is a constant-
rate hazard during load (~0.5%%/s from our numbers), shipped's expected rate
is only ~7%% per run -- and shipped has had maybe five runs on this rig.
"Shipped survives the same stream" may be nothing but exposure luck on a
shorter window. Combined with the constants that never fit guest corruption
(fixed host EIP, fixed cr2 across builds whose BSS moved), the residual is
now MOST LIKELY an emulator-fork serial/DPMI-host interaction, not a game
defect. Next probe when this thread resumes: the AWE-debug-kit pattern
pointed at the UART -- hook the fork's page-fault path on cr2==0x7000FA64 and
dump the last serial-IRQ deliveries + guest cs:ip history. (And run a
shipped-exe streak for the baseline exposure comparison.)
MEANWHILE, the torso BUTTON path is real end-to-end: TorsoSimulation carries
the donor's digital command handling (torso.cpp:557, binary @004b5cf0) --
elevate up/down, twist left/right with limit clamps, 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 dynamicsState (class size
unchanged). With 5.3.68's table fix, the RIO/TM twist buttons now land in
these members and move the torso -- and the canopy eye rides the twist chain.
--------------------------------------------------------------------------------
THE RESIDUAL FAULT, CAUGHT IN THE ACT -- AND THE INSIDE VIEW GETS ITS COCKPIT
--------------------------------------------------------------------------------
TWO instruments landed together this session:
1. VPX_PF_WATCH (emulator fork, cpu/paging.cpp): on any guest page fault at
the watched linear address, dump guest registers, the last 64 serial RX
deliveries (time + guest cs:eip at delivery + byte), the code bytes at the
faulting EIP and the stack top. serialnamedpipe's doReceive() feeds the
ring (VPX_SerialTraceByte). Armed via VPX_PF_WATCH=7000FA64 in podrun.sh
and launch_pod.ps1; the dump lands in dosboxlog.txt / pod_err.txt.
FIRST CATCH decoded the fault completely:
CS:EIP = 00FF:000066D4 (the DPMI host, flat selector)
EBX = F000CA60 <- an IVT ENTRY read as a dword: seg F000, off CA60
= a BIOS DEFAULT interrupt handler
access = [EBX+0x3004], and the segment's 0x80000000 base wrap gives
exactly cr2 = 0x7000FA64
So the host reads a probe word 0x3004 bytes past a REAL-MODE VECTOR VALUE
treated as a flat pointer. For the host's own handlers (low memory) that
read lands in mapped conventional memory and is harmless; for a BIOS
F000:xxxx default it lands in unmapped high linear space and faults. The
serial ring shows a steady 1-byte/1-3ms vRIO stream with NOTHING special
at the fault -- the stream only determines WHICH vectors the host walks
and when. The +0x3004 constant appears nowhere in DPMI32VM.OVL or
32RTM.EXE as an immediate, so it is composed at runtime; the probe now
also dumps code bytes at EIP + stack to close that gap on the next catch
(faulthunt.sh loops runs until it fires).
2. SHIPPED BASELINE: 4/4 clean. Consistent with the exposure theory
(expected ~0.6 faults if the hazard is proportional to the load window)
-- not yet discriminating. The probe data is the discriminator now.
THE INSIDE VIEW LOADS THE COCKPIT SKELETON. MAX.SKL solved the own-mech
question the authentic way: the X-variant naming convention (third letter X:
MAD->MAX, AVA->AVX, BAT->BAX, BLH->BLX, FIR->FIX, JAK->JAX, LOK->LOX, plus
numbered MAX1/MAX2 -- all 64 present in VIDEO/) selects a skeleton with the
SAME 25-joint chain but a single object: max_cop.bgf, the MAX_COP canopy
shell with the PUNCH-texel windows from the real-pod capture forensics. The
donor names the same mechanism from the decomp side (inside = SkeletonType_A
with '_cop' selection, btl4vid.hpp:678). Our mech case now derives the
inside name from the resource's skeleton (fallback to the body skeleton if
the X file is missing), so the pilot sits in the authentic cockpit instead
of inside the body model's torso panels.
--------------------------------------------------------------------------------
FAULT HUNT ROUND 2: THE BAD POINTER IS THE EMULATOR'S DEFAULT-HANDLER STUB
--------------------------------------------------------------------------------
The IVT scan on the second catch settles what EBX is: F000:CA60 is the
EMULATOR'S DEFAULT "unhandled interrupt" callback, the value filling every
vector nobody hooked -- it matched ~56 vectors (INT00, 06, 07, 2B-2D, and
most of 32-6F). Meanwhile the hardware-IRQ block IVT[08-0F] reads
0D1F:0777..0793 -- the DPMI host's own real-mode reflector stubs, 4 bytes
apart. So the serial IRQs ARE properly hooked; the host read the default
value from one of the UNHOOKED vectors and probed [value + 0x3004] as a flat
pointer, which wraps under the segment base to the famous cr2.
Register constants across all catches (EAX=1 EBX=F000CA60 ECX=0 EDX=FFFFFFFF,
same ESP): a deterministic host code path, not wreckage. Two more facts from
this round: the "stack" dwords from catch #1 decode as 16-bit REAL-MODE code
-- because the dump read ESP as a bare linear address; SS=0107 has a nonzero
base. Fixed (SegPhys(ss)/SegPhys(cs) now applied; rebuilt, ready for the
next catch). And the code bytes at linear EIP read as ZEROS -- consistent
with DPMI32VM being a VIRTUAL-MEMORY host that pages its own regions: the
page holding the faulting code may already be evicted when the dump runs.
"Reference to a page you don't own" is the host PAGER's message: the fault
address falls outside every region it owns, so it aborts instead of paging.
WHAT'S NAILED: host-side deterministic path; trigger correlated with the
serial stream's presence but not with any particular byte; the bad read =
[unhooked-vector-default + 0x3004]. WHAT'S OPEN: which vector, and which
host routine. Next catch carries corrected stack/code dumps; if those name
a return chain, the routine falls out. Rate note: 4 faults in the last 10
vRIO-live runs -- hovering near the 25-40%% band, still load-window-only.
--------------------------------------------------------------------------------
THE FAULTING INSTRUCTION, DECODED: A HANDLER-CHAIN WALK WITH A BAD NODE
--------------------------------------------------------------------------------
The SegPhys-corrected dump finally read real code (the earlier zeros were the
probe reading EIP as a bare linear address -- CS=00FF has a base). At the
fault:
66CF ... <- loop top
66D4 push 0
66D6 push edi ; 00017574
66D7 push ebx ; F000CA60 <- the node
66D8 push esi ; 00017640
66D9 call dword near [ebx+4] <- FAULTS
66DC cmp eax,0
66DF jz 6E81 ; a handler claimed it -> done
66E5 mov ebx,[ebx] ; next node
66E7 jmp 66CF ; loop
That is a LINKED-LIST WALK with a per-node CALLBACK: node = {next @ +0,
handler @ +4}, four args pushed, "handled" if the handler returns nonzero.
Textbook DPMI exception/interrupt handler chain.
The arithmetic closes exactly: the read is DS_base + ebx + 4, and
0x7000FA64 - 0xF000CA64 = 0x80003000, so DS has base 0x80003000 and the
famous cr2 is just that wrap. (My earlier "[EBX+0x3004]" reading was
fabricated from the cr2 alone -- there is no 0x3004 displacement; the
instruction is a plain +4. That is why the constant appeared in no binary.)
So the defect is: a chain node's NEXT pointer holds 0xF000CA60, the value
DOSBox-X (and a real BIOS, comparably) parks in unhooked IVT slots -- the
walk expected a terminator and got an interrupt vector. The host then reads
that value's +4 as a function pointer and dies. Every catch has identical
registers (EAX=1 EBX=F000CA60 ECX=0 EDX=FFFFFFFF, same ESP/EBP), so this is
one deterministic path, entered often enough that a 60-second load window
catches it ~25-40%% of the time and a 15-second one (shipped) mostly does not.
Still open: WHICH routine owns the chain and where the bogus next came from.
The probe now walks the EBP frame chain to name the caller; faulthunt.sh is
looping for the next catch.