diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md
index 04a86235..464a9667 100644
--- a/docs/dev/GE-ENV-PROBES.md
+++ b/docs/dev/GE-ENV-PROBES.md
@@ -17,7 +17,7 @@ Two classes:
| Env var | File:line | What it does | Status |
|---|---|---|---|
-| `GE_PCDUMP` | `port/fast3d/gfx_opengl.cpp:1079` (`is_pcdump`), `gfx_opengl.cpp:700`, `port/src/video.c:188`, `port/src/config.c:46` (`configGetFrameDump`) | Per-frame framebuffer PPM dump to `$PCDUMP` dir (verification-ritual golden captures, `tools_pc/framediff.py`/`pixcount.py`). Also settable via `[Debug] FrameDump` ini (env wins). | **live** |
+| `GE_PCDUMP` | `port/fast3d/gfx_opengl.cpp:1079` (`is_pcdump`), `gfx_opengl.cpp:700`, `port/src/video.c:188`, `port/src/config.c:46` (`configGetFrameDump`) | Per-frame framebuffer PPM dump to `$PCDUMP` dir (verification-ritual golden captures, `tools_pc/framediff.py`/`pixcount.py`). Also settable via `[Debug] FrameDump` ini (env wins). **M-33/D168: the writer now emits rows top-to-bottom — captures before that fix are vertically flipped.** | **live** |
| `GE_INPUTSCRIPT` | `port/src/input.c:306` | Headless scripted controller-0 input: `":,…;…"` (`SUP/SDOWN/SLEFT/SRIGHT/SNONE` sustain, buttons pulse). Sole input source when set. | **live** |
| `GE_INPUTLOG` | `port/src/input.c:329`, `port/src/config.c:53` (`configGetInputLog`) | Logs computed pad state (buttons + stick) each poll when non-zero. Also `[Debug] InputLog` ini. Also traces D165 `menuptr est/tgt/eff/stick`. | **live** |
| `GE_STARTMENU` (+ `GE_STARTMENU_PAGE`, `GE_STARTMENU_DIFF`) | `src/game/lv.c:398-402` (`#ifdef PORT`) | Boot straight into a front-end menu id (13=MISSION_COMPLETE, 10=BRIEFING, 7=MISSION_SELECT, 12=MISSION_FAILED, 6=MODE_SELECT); `_PAGE`=folder row (def 1=Dam), `_DIFF`=0..3. Crash-test a screen fast. | **live** |
@@ -26,7 +26,7 @@ Two classes:
| `GE_D160` | `src/boss.c:744`, `src/game/bondview2.c:791`, `src/aicommands.def:10055,10181,10236,10242,10247` (all `#ifdef PORT`) | Dam exit-cutscene (D148/D160) diagnostic: trace points in `bossReturnTitleStage`, `bondviewSetCameraMode`, and AI cmds `EndLevel`/`exit_level`/`CameraLookAtBondFromPad`/`CameraSwitch`. **Investigation in progress** — user owes a `GE_D160=1` Dam run. | **live** |
| `GE_DTEX` | `port/fast3d/gfx_pc.cpp:1001` | Per-`import_texture` param log (dims / line / size / lod / gen_mipmaps). Used to root-cause RC2 and D159. Kept as an inert diagnostic. | **live** (RC2/RC3 not finished) |
| `GE_TEXDUMP` | `port/fast3d/gfx_pc.cpp:1020`, `port/fast3d/gfx_opengl.cpp:700` | PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx/pal[0..3]` line. Root-caused D161 (Depot ceiling). Kept as an inert diagnostic. | **live** (RC3 not finished) |
-| `GE_D116` | `port/fast3d/gfx_pc.cpp:1710,2362`, `src/game/textrelated.c:265,541` (cached `ge_d116`) | HUD/text X-mirror (D114/D116) vbo/uv trace. Bug is **parked / DO-NOT-re-static-trace** (PORT-LEARNINGS §D2) but the probe is cheap (cached int) — leave until that cosmetic bug is picked up with RenderDoc. | idle (parked bug) |
+| `GE_D116` | `port/fast3d/gfx_pc.cpp:1710,2362`, `src/game/textrelated.c:265,541` (cached `ge_d116`) | HUD/text "X-mirror" (D114/D116) vbo/uv trace. **D114/D116 CLOSED — NOT A BUG (M-33/D168): the captures were upside-down, not mirrored.** Probe is now dead scaffolding — strip on the next cleanup pass. | dead (D114/D116 closed) |
## Dxx diagnostic probes — finding closed unless noted
diff --git a/docs/dev/GRAPHICS-BACKLOG.md b/docs/dev/GRAPHICS-BACKLOG.md
index 74ce6f0b..03cea743 100644
--- a/docs/dev/GRAPHICS-BACKLOG.md
+++ b/docs/dev/GRAPHICS-BACKLOG.md
@@ -8,12 +8,12 @@ once the game is playable start-to-finish without crashes.
| ID | Symptom | Status / notes |
|----|---------|----------------|
-| **D114/D116** | HUD text + ammo digits render each glyph individually X-mirrored (screen positions correct, texture content flipped). Likely also explains "inverted" guard skins / mislocated door props. | Root cause NOT found after 4 sessions (M-6/7/8/11). Every stage `textRenderGlyph`→GBI→fast3d→`buf_vbo`→GL runtime-verified non-mirrored (`GE_D116` `[D116/vbo]` probe). Contradiction unresolved. **Next attempt requires a RenderDoc/apitrace capture of one glyph texrect, or the asymmetric-1-texel-texture experiment** — do NOT re-run the static trace, and do NOT apply a global texrect S-swap (mirrors the whole screen). |
+| ~~**D114/D116**~~ | ~~HUD text + ammo digits render each glyph individually X-mirrored~~ | **CLOSED — NOT A BUG (M-33, §F D168).** The `GE_PCDUMP` PPM writer never reversed `glReadPixels` rows, so every capture was upside-down; "mirrored" was a misread of inverted asymmetric content. The game renders correctly on hardware (developer-confirmed). Writer fixed (`gfx_opengl.cpp`). "Every stage verified non-mirrored" was correct — there was no mirror. |
| **D74 wrap-block** | `gfx_pc.cpp:1546/1551` PORT block is dead code: guard `cms & G_TX_WRAP` with `G_TX_WRAP==0` is always false; and it indexes per-texunit `[2]` arrays with the vertex index `i` (0..2) → OOB if reached. | Latent. Harmless for CLAMP glyphs; could affect wrapped textures on triangles (room panels, model skins). Fix: `cms == G_TX_WRAP`, and compute the tile window once per tile not per vertex. Verify against `tools_pc/golden/` after. |
-| **D75** | Nintendo logo mispositioned; gun-barrel Bond model absent; cast-roll character models absent. (Rareware logo OK.) | Front-end 3D model path. Partially overlaps D114 (shared mirror on asymmetric content). Triage plan in `findings.md` §F D75. |
+| **D75** | Nintendo logo mispositioned; gun-barrel Bond model absent; cast-roll character models absent. (Rareware logo OK.) | Front-end 3D model path. The "overlaps D114 shared mirror" note is withdrawn (M-33, D168 — D114 was a flipped capture). "Logo mispositioned" should be re-judged from a right-way-up capture; the absent-models half stands. Triage plan in `findings.md` §F D75. |
| **D76** | 2D disclaimer screen only partially drawn. | §F D76. |
| **D77** | No audible music on PC. | Phase 3 (audio). §F D77. |
-| **D116 (HUD)** | (subset of D114) proportional-font strings + ammo counter mirrored. | Same root cause as D114. |
+| ~~**D116 (HUD)**~~ | ~~proportional-font strings + ammo counter mirrored~~ | **CLOSED — NOT A BUG (M-33, §F D168).** Same capture-orientation artifact as D114. |
| **D148** | Level-end cinematics don't play. Dam: hitting the exit trigger should show Bond rappelling down the dam (the bungee jump); instead it cuts straight to the mission-complete report. | Scripted cutscene / cinematic-camera + Bond rappel anim. Found M-27 during the first full Dam→Facility playthrough. Progression is unaffected — the report and next briefing load fine. **M-31 (§F D160):** static-traced the Dam end ailists (`ai_24`→`ai_17`, `camera_switch`→`CutsceneRecord` type-46 lookup). Diagnostic `GE_D160=1` shipped (`c95713f5`) — user runs Dam to the exit with it set; the trace pins whether the cutscene AI runs, and whether the propDef-index walk (D122/D132 family) hands `camera_switch` a bogus cutscene record. Leading hypothesis: propDef `sizepropdef` stride desync (medium confidence), not D75 anim-model. |
| **D149** | Front-end MISSION COMPLETE / mode-select 3D models (dossier, wallets) render as garbled full-screen geometry or not at all — one of the compiled sub-DLs (seg5+0x9ee4) is malformed, D146 now skips it instead of aborting. | D75 family. §F D146. |
diff --git a/docs/dev/TEXTURE-GLITCH-ANALYSIS.md b/docs/dev/TEXTURE-GLITCH-ANALYSIS.md
index 95d07615..74701710 100644
--- a/docs/dev/TEXTURE-GLITCH-ANALYSIS.md
+++ b/docs/dev/TEXTURE-GLITCH-ANALYSIS.md
@@ -7,6 +7,16 @@ Companion artifacts: `` (outside repo).
## 0. Current status (M-31) — READ THIS FIRST
+> **M-33 overlay (D168):** every `GE_PCDUMP` / F12 screenshot this doc's
+> analysis relied on was **vertically flipped** — the PPM writer never reversed
+> `glReadPixels` rows. This is what the D114/D116 "HUD/text X-mirror" finding
+> actually was (now closed, not a bug). It also contaminates the **RC1** row
+> below: the wallet-Bond photo's "180°-rotated / mis-transformed" description
+> was made from an upside-down capture, so at least the vertical component is
+> the capture bug, not the model transform. Re-judge RC1 from a fresh
+> right-way-up capture before doing any more work on it. Writer fixed
+> (`port/fast3d/gfx_opengl.cpp`).
+
The 2026-08-29 body below is the original diagnosis. Several root causes have
since been fixed or retracted. Authoritative current state:
diff --git a/docs/dev/findings.md b/docs/dev/findings.md
index ca835b90..62df6be3 100644
--- a/docs/dev/findings.md
+++ b/docs/dev/findings.md
@@ -380,13 +380,14 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D59–D68 | intro render: blood-RLE clobber, DMA validate, OSMesgQueue, HEADS/BODIES sentinels, romCopy width, image_entry, Globalimagetable BE→LE | resolved |
| D69 · D78–D84 | stage load (`load_bg_file`): bg/stan offline sidecar (`d69_emit.py`) + StandTile/bg_room_data ABI | resolved |
| D70–D74 | intro-logo pixels: C-array bswap, UV path, sinf/cosf `DVAL()`, texture-import truncation | resolved |
-| D75 · D77 | front-end 3D model transforms · audio | OPEN (parked). **M-32 triage (see §F "D75 ADDENDUM"): (a) D73-scope-gap RULED OUT (gu tree fully endian-clean). Splits in two: Bug 1 = logo/photo transform = the parked D114/D116 fast3d viewport mirror (not game code). Bug 2 = absent animated models = category (b). M-32b runtime probe (`GE_D75=1`): `render_pos`/`dynAllocate`-arena hypothesis RULED OUT (render_pos valid + fresh each frame), model instances valid (nMtx 21/1), zero fast3d DL warnings — failure is downstream in `drawjointlist`/`dotube` vtx/node-DL resolution or an off-screen `basemtx`. Needs a drawjointlist-level probe.** |
+| D75 · D77 | front-end 3D model transforms · audio | OPEN (parked). **M-32 triage (see §F "D75 ADDENDUM"): (a) D73-scope-gap RULED OUT (gu tree fully endian-clean). Splits in two: Bug 1 = logo/photo transform = the parked D114/D116 fast3d viewport mirror (not game code). Bug 2 = absent animated models = category (b). M-32b runtime probe (`GE_D75=1`): `render_pos`/`dynAllocate`-arena hypothesis RULED OUT (render_pos valid + fresh each frame), model instances valid (nMtx 21/1), zero fast3d DL warnings — failure is downstream in `drawjointlist`/`dotube` vtx/node-DL resolution or an off-screen `basemtx`. Needs a drawjointlist-level probe. M-33: the "Bug 1 = D114/D116 fast3d viewport mirror" half is retracted — D114/D116 were an upside-down capture (D168). "Logo mispositioned" needs re-checking against a right-way-up capture; the absent-models half (Bug 2) stands.** |
| D76 · D164 | disclaimer/legal screen only draws line 1 — **root-caused (M-31)**: `constructor_menu00_legalscreen` text loop bounds `legal_text_end` on `&legalscreen_MRD`, a linker-adjacency assumption that mingw breaks (`legalscreen_MRD` links 0x60 *before* `legalpage_text_array`) → `do{}while(ptr **M-33 CORRECTION (D168): this finding is retracted as a capture artifact.**
+> Every `GE_PCDUMP` PPM was written upside-down (`gfx_opengl_dump_bound_fbo`
+> did not reverse `glReadPixels` rows). The developer confirms the game renders
+> correctly on real hardware. The "shared fast3d mirror" this and the D116
+> entries chase never existed — the probes kept coming back clean because there
+> was nothing wrong; the analysts were reading inverted screenshots of
+> asymmetric content (guards, text, logo) and calling them "mirrored". The PPM
+> writer is fixed. The genuinely-open item that remains is D75 Bug 2 (front-end
+> animated models absent), which is unrelated. Original text kept below for
+> history.
+
**D114 (session M-6) — character inversion / mispositioned crate:
OPEN, matrix chain verified sane, residual is a shared fast3d mirror
(D75 class).** A subagent statically traced the whole joint-matrix chain
@@ -3335,6 +3347,13 @@ watch-preview Model pool (`watchRwPool[0xC8]`) is N64-sized and the
inline Model overruns live watch fields. See the audit doc for the full
table and recommended fix order.
+> **M-33 CORRECTION (D168): retracted as a capture artifact — see the D114
+> correction above and D168.** "Every stage probed is clean, yet the output is
+> mirrored" was the tell: the output was not mirrored, it was upside-down (the
+> PPM writer never reversed `glReadPixels` rows). No `GE_D116` S-swap / U-flip
+> was ever warranted. Probe scaffolding (`GE_D116` in `textrelated.c` /
+> `gfx_pc.cpp`) can be stripped. Original text kept below for history.
+
**D116 (session M-7) — the HUD/menu text mirror (D75 class) is a
per-glyph TEXTURE-space flip, NOT a screen/framebuffer/matrix mirror.**
Rebuilt at D115 (`14b6b432`), build green, captured `-level_09` frames
diff --git a/docs/porting-notes.md b/docs/porting-notes.md
index 46d55743..9617910c 100644
--- a/docs/porting-notes.md
+++ b/docs/porting-notes.md
@@ -176,8 +176,10 @@ through a converter or a runtime bswap fixup reads scrambled.
`matrixmath.c` (`matrix_4x4_set_lookat*`, `_set_projection`,
`_f32_to_s32`) is likewise native-LE (D114). **Do not re-audit gu or
matrixmath for a "float endianness" bug** — if a matrix comes out wrong
- on PC the cause is upstream data, a struct-field pun (D100/D140/D156), or
- a fast3d gap (D114/D116 viewport mirror), never these files.
+ on PC the cause is upstream data, a struct-field pun (D100/D140/D156),
+ never these files. (The "D114/D116 viewport mirror" example is withdrawn —
+ M-33/D168 showed D114/D116 were an upside-down `GE_PCDUMP` capture, not a
+ real flip.)
- Header offset tables / pointers: D54 (cseq ALMidiHdr), D68
(Globalimagetable), D87 (ramromfilestructure), D88 (Usetup* tables).
- Negative-terminated index chains (`PointUsage[]`) in converted model
@@ -372,19 +374,23 @@ through a converter or a runtime bswap fixup reads scrambled.
indexed by texunit, gated on `Video.WrapFix`); RC3/D167 adds the non-PoT
mask-period case to the same block. Still default OFF.
-## D2. The HUD/model X-mirror (D114/D116) — DO NOT re-static-trace
+## D2. The HUD/model "X-mirror" (D114/D116) — RESOLVED: it was an upside-down capture
-Sessions M-6/M-7/M-8/M-11 all reached the same wall: `textRenderGlyph`
-→ GBI → fast3d `gfx_dp_texture_rectangle` → `buf_vbo` → GL are **each
-runtime-verified non-mirrored** (`[D116/vbo]` probe: x-left↔u=0), yet
-glyphs render X-flipped. M-11 additionally confirmed the ammo digits use
-the *same* `textrelated.c` path (via `gunfire.c:5906`), no separate
-renderer. Every prior attempt to "fix" it drifts toward a global
-S-swap that mirrors the whole screen — a bad trade for a cosmetic bug.
-**Next attempt needs a RenderDoc/apitrace capture of one glyph texrect
-OR the asymmetric-1-texel-texture experiment — nothing else.** Cosmetic,
-deprioritised below level-progression / crash work. See
-`docs/dev/GRAPHICS-BACKLOG.md`.
+**M-33 (finding D168).** There was no mirror. `gfx_opengl_dump_bound_fbo`
+(`port/fast3d/gfx_opengl.cpp`) wrote `glReadPixels` output — bottom-row-first,
+GL origin — straight into a top-row-first P6 PPM, so **every `GE_PCDUMP` and
+F12 capture was vertically flipped**. Sessions M-6/M-7/M-8/M-11 kept finding
+`textRenderGlyph` → GBI → fast3d → `buf_vbo` → GL **each verified
+non-mirrored** — because nothing *was* mirrored; they were staring at
+upside-down screenshots of asymmetric content (text, ammo digits, guard skins,
+the Nintendo logo) and reading "inverted" as "X-mirrored". The developer
+confirms the game renders correctly on real hardware.
+
+The lesson worth keeping: **when every stage of a pipeline probes clean but
+the output "looks wrong", suspect the observation tool before adding a
+correction.** A cosmetic defect that no probe can localise after four sessions
+is a strong signal that the defect isn't in the code. Fix: PPM writer now
+emits rows top-to-bottom; `tools_pc/golden/*.png` were flipped to match.
## D3. GCC/mingw makes an all-non-negative `enum` UNSIGNED
diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp
index 9f75aba4..66e5aea1 100644
--- a/port/fast3d/gfx_opengl.cpp
+++ b/port/fast3d/gfx_opengl.cpp
@@ -1087,7 +1087,15 @@ extern "C" bool gfx_opengl_dump_bound_fbo(uint32_t width, uint32_t height, const
FILE* f = fopen(path, "wb");
if (!f) { free(pixels); return false; }
fprintf(f, "P6\n%u %u\n255\n", width, height);
- fwrite(pixels, 1, n, f);
+ /* D168: glReadPixels() returns rows bottom-to-top (GL framebuffer origin is
+ * bottom-left); PPM P6 is top-row-first. Emit rows in reverse so the capture
+ * is the right way up. Previously written unreversed -> every GE_PCDUMP was
+ * vertically flipped (the source of the bogus D114/D116 "text X-mirror"). */
+ {
+ size_t stride = (size_t)width * 3;
+ for (uint32_t row = 0; row < height; row++)
+ fwrite(pixels + (size_t)(height - 1 - row) * stride, 1, stride, f);
+ }
fclose(f);
free(pixels);
return true;
diff --git a/tools_pc/golden/README.md b/tools_pc/golden/README.md
new file mode 100644
index 00000000..89445823
--- /dev/null
+++ b/tools_pc/golden/README.md
@@ -0,0 +1,19 @@
+# Golden baseline frames
+
+`frame_NNNNNN.png` — reference `GE_PCDUMP` captures for `framediff.py`
+(`-level_09` BUNKER1 intro pan). Compared structurally, not exactly — the port
+is not frame-deterministic (see `docs/internals.md` "D117").
+
+## D168 re-orientation (2026-09-01)
+
+The `GE_PCDUMP` PPM writer (`port/fast3d/gfx_opengl.cpp
+gfx_opengl_dump_bound_fbo`) used to emit `glReadPixels` rows unreversed, so
+every capture — and therefore these goldens — was **vertically flipped**. The
+writer now emits rows top-to-bottom. The PNGs in this directory were flipped
+vertically in place to match; content is otherwise unchanged. Regenerate them
+properly from a fresh run when convenient:
+
+```sh
+GE_PCDUMP="200-440:120" ./build-pc/ge007.x86_64.exe -level_09
+python tools_pc/framediff.py ppm --update
+```
diff --git a/tools_pc/golden/frame_000200.png b/tools_pc/golden/frame_000200.png
index 832c303e..065fadca 100644
Binary files a/tools_pc/golden/frame_000200.png and b/tools_pc/golden/frame_000200.png differ
diff --git a/tools_pc/golden/frame_000320.png b/tools_pc/golden/frame_000320.png
index 40fbec8d..6180666a 100644
Binary files a/tools_pc/golden/frame_000320.png and b/tools_pc/golden/frame_000320.png differ
diff --git a/tools_pc/golden/frame_000440.png b/tools_pc/golden/frame_000440.png
index da67c52b..d1b6033f 100644
Binary files a/tools_pc/golden/frame_000440.png and b/tools_pc/golden/frame_000440.png differ