Conversation
CRT presets for the three monitors matching the eras this core emulates, plus one small shader of our own that the stock chain cannot replace. Each preset is the stock 11-pass crt-guest-dr-venom chain with a horizontal-bandwidth pass prepended, tuned per monitor and built for this core's native 800x280 output (one source row per real CPC scanline). Why the extra pass: crt-guest's h_sharp kernel is exp2(-h_sharp * w^2), so even at its floor of 1.0 a tap three texels out weighs about 0.002 -- an effective reach under +/-1.5 texels. In this core's output a Mode 1 pixel is 2 texels wide and a Mode 0 pixel is 4, so h_sharp can never blend adjacent Mode 0 pixels. On real hardware Mode 0 is barely distinguishable from Mode 1; without this pass it renders as clean fat blocks. The pass is horizontal-only and exposes "CPC video bandwidth (texels)" (0.0-4.0, default 1.10, 0 disables). For reference, Sugarbox's own CRT shaders do channel masks only and apply no horizontal filtering at all. Hardware grounding, from the Amstrad service manuals: CTM644 tube 3701B22-TC20, 37cm/14in, slot mask, 24kV, B22 = P22 triad GT65 tube Orion 310GNB31, 12in, phosphor P31 (confirmed, not inferred) CM14 tube Orion 370KRB22-TC21, 14in, Toshiba blackstripe licence Both colour tubes are stripe-phosphor, so the presets model a slot mask and disable the dot mask. A monochrome tube has no mask at all, which is why GT65 sets both to zero and carries pronounced scanlines while the colour presets keep them faint and let mask texture dominate -- the documented difference between the two families. P31 is short persistence, so the GT65 afterglow is brief and green-only. Stripe pitch, deflection angle and video bandwidth are NOT in the service manuals for any of the three; anything geometric here is era-typical rather than measured, and the files say so inline. amstrad-monitor-cm14.glslp is optically identical to the CTM644 preset and states this outright: nothing found distinguishes the two tubes visually, and it is kept for era clarity only. Paths are relative, so the presets install as <shaders_glsl>/amstrad/ beside RetroArch's stock crt/ directory. See shaders/amstrad/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
…ubling CPCCore's Monitor writes one CPC scanline into every OTHER buffer row -- GetVideoBuffer(y) returns row 2y, which is the upstream convention, not our choice: CPCCore's own UnitTests/Display.cpp:52 does the same. Nothing in the engine ever fills the odd rows; filling them is the display implementation's job, which is what IDisplay::SetScanlines() is for, and ours is empty. So the core was handing the frontend a frame in which half the rows were black: a probe of a booted 6128 measured 280 of 280 odd rows fully black. That is a scanline CRT effect baked into the core by omission. Emit the real lines instead and leave CRT simulation to the frontend's shaders. Stepping the pitch by two rows skips the blank ones at zero cost -- no copy, no per-frame work. crop_y_ is even in both border modes (84 and 8), so row parity survives the crop. This is not cosmetic. crt-geom computes ilfac = clamp(floor(InputSize.y/200.0), 1.0, 2.0) and treats the source as interlaced when that reaches 2, i.e. at height 400 and above, with interlace_detect defaulting to on. The old 800x560 output therefore read as an interlaced signal and alternated fields every frame. 800x280 is also the native line count every other CPC core reports: cap32 272, MAME's gx4000 272. Do NOT "fix" the blank rows by duplicating each row instead. 560 rows is precisely what defeats a CRT shader, which reads each source row as one physical scanline. With native output there is nothing left to toggle, so no core option is added. The OSK had to be re-laid-out for the halved space: its grid panel was 5*50+50 = 300 rows, which no longer fits the 240 of the "normal" border mode. Vertical constants are halved and OskDrawGlyph/OskDrawText gain a separate scale_y; glyphs now use scale_x 2 with scale_y 1, which renders square because at 4:3 each row is displayed about twice as tall as a column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
… P31 A monochrome CPC monitor is fed R, G and B down the DIN and sums them in analogue hardware. It does not apply a perceptual TV weighting, so Rec.601 was the wrong model: with blue at 29/256 it rendered the default Mode 1 paper -- CPC Blue -- at G=13 against G=216 text, a ratio of 0.06. That is effectively black, and it is why the green monitor never looked right. Measured against a photograph of a real GT65 showing the 6128 boot screen: paper G=102, text G=255, with the camera's black floor at G=42, i.e. a ratio of 0.28. An equal-weight sum predicts 0.25; Rec.601 predicts 0.06. After this change the core measures 0.217. The tint was wrong too. The GT65's tube is an Orion 310GNB31, phosphor P31, confirmed from the CPC664/6128 service manual rather than inferred. P31's chromaticity (0.210, 0.710) lies outside sRGB, so gamut mapping it by desaturating toward D65 until red reaches zero gives (0, 1.0, 0.53) once gamma encoded -- a green leaning blue, where the old tint was symmetric (40, 256, 40). The photograph measures a blue:green of 0.66, so the lean is real and 0.53 is conservative. Amber keeps its own tint: no phosphor is documented for the MM12, and sources disagree on whether it is white or amber, so there is nothing to correct it against. It does pick up the new luma model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
Seven mouse lines were logged at INFO on every frame with any pointer activity, which floods the frontend log during ordinary play. The key matrix logging next to it was already DEBUG. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
CPCCore's CRTC decides a lightgun hit with
((gate_array_)->monitor_)->y_ * 2 == gun_y_ (CRTC.cpp)
an exact equality against an always-even number. The wrapper fed it
((gun_y + 0x8000) * CropHeight()) / 0x10000 + CropOffsetY(), which is odd
for roughly half of the 65536 possible inputs. At those positions no shot
could ever register, whatever the player did -- which fits the long
standing "gun hit detection never worked" history.
Scale against the displayed line count and double, so the result is always
even and still spans the crop.
To make this testable at all, the geometry constants and the coordinate
transforms move to SugarLibRetro/display_geometry.h: header-only, pure
arithmetic, no libretro state. libretro.cpp is a single translation unit of
static functions and frontend callbacks and cannot be unit tested; this is
the part that has invariants worth pinning. The rest of the change is pure
code motion -- rendered geometry was re-measured at 800x280 before and
after.
The tests live in SugarLibRetro/tests/ rather than beside libretro.cpp
because SugarLibRetro/CMakeLists.txt globs *.cpp into the shared library.
They sweep the whole 16-bit gun range in both border modes and assert:
parity (the invariant CRTC.cpp actually requires), that every displayed
line is reachable, bounds and monotonicity, and that the old formula
produced odd values for about half its inputs. That last one keeps the bug
documented rather than merely fixed. Verified by mutation: restoring the
old formula fails GunYIsAlwaysEven and nothing else, so the test has teeth
-- note that "every line reachable" passes even with the bug present.
Tests are opt-in via -DSUGARLIBRETRO_BUILD_TESTS=ON, default OFF so the
REG-Linux package build and the libretro buildbot never configure
googletest. Two details that would otherwise bite:
- enable_testing() is called at the top level, or ctest finds nothing from
the build root however many gtest_discover_tests() the subdirectories do.
- gtest_force_shared_crt is forced OFF before googletest is added, because
this project rewrites /MD to /MT and MSVC would otherwise fail with
LNK2038.
zlib registers its own CTest suite, which appeared in our results the
moment enable_testing() existed; it is switched off. The CMake minimum goes
from 3.0 to 3.16 (what CPCCore already declares): CMake 4.x rejects a 3.0
minimum outright, which would fail CI at configure time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
GitHub Actions workflow: configure with tests on, build, run ctest, upload the built core as an artifact. Both platforms, fail-fast off, so a Windows break does not hide a Linux one. Installs Python explicitly. The root CMakeLists blanks Romantic Robot's bundled Multiface II firmware before compiling, and without an interpreter that step only warns -- which would mean CI publishing an artifact with somebody else's ROM inside it. appveyor.yml and .travis.yml are left alone; both are long dead and removing them is a separate decision. Verified by running the workflow's exact commands locally on Linux: configure, build, ctest (7/7), and the artifact appears at the path the upload step looks for. The Windows leg and the Actions runners themselves are unverified until this is pushed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
The core has never compiled on Windows: libretro.cpp includes <unistd.h>, which MSVC does not ship, and the build died at libretro.cpp(31,10): error C1083: Cannot open include file: 'unistd.h' This predates the CI added in this branch -- the include is on master. The new Windows job simply ran a compiler that had never been pointed at this file before. The only thing the file wants from unistd.h is access(), used at five call sites for F_OK/R_OK/W_OK existence and permission checks. The Microsoft CRT provides it as _access() in <io.h> and does not define the mode constants, so both are supplied under _MSC_VER. Linux is unaffected: same build, tests still 7/7. Whether anything further in the file upsets MSVC is for the Windows job to say. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
Four native targets instead of two: linux-x86_64, linux-arm64, windows-x86_64, windows-arm64. Standard GitHub-hosted runners are free and unlimited on public repositories, and that now includes arm64 on both platforms (ubuntu-24.04-arm and windows-11-arm, both generally available), so all four are native builds -- no cross-compiling and no QEMU. arm64 is the interesting one for us: REG-Linux ships this core on aarch64 boards, and until now nothing ever compiled it for that architecture. Still uncovered, and not solvable with hosted runners: armv7 and riscv64, which REG-Linux also targets. Both would need a cross toolchain or QEMU, which is a different job shape. macOS on both architectures is available free as well, and is not enabled here because nobody has ever built this core on macOS and an untried target would just make the matrix red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
Six native targets now: Linux, Windows and macOS, each on x86_64 and arm64. macOS runners are free on public repositories too, arm64 (macos-latest) and Intel (macos-15-intel) alike. This core has never been built on macOS, so unlike the other five this one is a genuine question rather than a formality. CMake gives a SHARED library the .dylib suffix there. fail-fast is off, so whatever macOS says cannot hide the result of any other target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
Two architectures REG-Linux ships on that have no GitHub-hosted runner. Cross-compiled on an x86_64 runner with the distro cross toolchains, and the tests are then RUN under qemu-user through CMAKE_CROSSCOMPILING_EMULATOR -- so these jobs prove the code executes on the target architecture, not merely that it compiles. It also lets gtest_discover_tests enumerate the tests at build time, which it cannot do for a foreign binary otherwise. One baseline build per architecture, deliberately. Per-core tuning belongs in the REG-Linux per-CPU prebuilds, not in this repo's CI. armv7: armv7-a + VFPv3-D16 + Thumb-2, hard float -- the armhf baseline and the lowest common denominator across ARMv7 application cores, so one binary covers every ARMv7 SoC. NEON is deliberately left off: it is optional on ARMv7, notably on some Cortex-A9 configurations, and requiring it would quietly exclude part of the hardware this is meant to cover. riscv64: RV64GC (rv64imafdc, lp64d), the distro default. No vector extension, which is not present on all the RISC-V boards in question. The toolchain files keep CMAKE_FIND_ROOT_PATH_MODE_PROGRAM at NEVER so the host python3 is still found for the Multiface ROM blanking step, while libraries and headers come from the sysroot. Each job also runs file(1) on the result and fails if it is not an ARM or RISC-V object, so a silently-native build cannot pass as a cross build. Unverified locally: the cross toolchains are not installed on this machine, so both jobs are first exercised by this push. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
Moves the submodule from bb4f1d2 to 559bfe2, picking up two upstream commits: 97d1453 Fix the #43 issue, explicit PPI byte from the name field (#48) 559bfe2 Save which cartridge bank is selected in the machine state (#49) The second is ours, merged upstream. Without this bump the core would still build against a CPCCore whose save states forget which cartridge bank the program was using, so a state taken on anything but the first bank of a cartridge larger than 512 KB resumes on the wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy
rtissera
force-pushed
the
native-geometry-and-monitor-presets
branch
from
September 19, 2026 22:52
63920a2 to
c3ed098
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Six commits. The first is the one that matters; the rest follow from it or were found on the way.
Native output: 800x280 instead of 800x560 with every other row blank
CPCCore's
Monitorwrites one CPC scanline into every other buffer row —GetVideoBuffer(y)returns row2y. That is the upstream convention, not ours: CPCCore's ownUnitTests/Display.cpp:52does the same. Filling the odd rows is the display implementation's job, which is whatIDisplay::SetScanlines()is for, and ours was empty.So the core was handing the frontend frames in which half the rows were black. Measured on a booted 6128: 280 of 280 odd rows fully black. That is a scanline CRT effect baked into the core by omission.
Fixed by stepping the pitch by two rows — no copy, zero per-frame cost.
This is not cosmetic.
crt-geomcomputesilfac = clamp(floor(InputSize.y/200.0), 1.0, 2.0)and treats the source as interlaced once that hits 2, i.e. at height 400+, withinterlace_detecton by default. The old 800x560 output read as an interlaced signal and alternated fields every frame. 800x280 is also what every other CPC core reports: cap32 272, MAME's gx4000 272.Not fixed by duplicating rows into the blanks: 560 rows is exactly what defeats a CRT shader, which reads each source row as one physical scanline.
The OSK had to be re-laid-out — its grid panel was 300 rows, which no longer fits the 240 of "normal" border mode.
Lightgun Y parity
CPCCore's CRTC decides a hit with
monitor_->y_ * 2 == gun_y_— an exact equality against an always-even number. The wrapper fed it a value that was odd for roughly half of the 65536 possible inputs, so at those positions no shot could ever register. Fits the long-standing "gun hit detection never worked" history.Monitor emulation: the mono path was using the wrong luma model
A monochrome CPC monitor is fed R, G and B down the DIN and sums them in analogue hardware; it does not apply a perceptual TV weighting. With Rec.601's blue at 29/256, default Mode 1 paper — CPC Blue — rendered at G=13 against G=216 text, a ratio of 0.06. Effectively black.
Measured against a photograph of a real GT65 showing the same boot screen: 0.28. Equal-weight sum predicts 0.25, Rec.601 predicts 0.06. After the change the core measures 0.217.
The tint was wrong too. The GT65's tube is an Orion 310GNB31, phosphor P31, confirmed from the CPC664/6128 service manual. Gamut-mapping P31's chromaticity (0.210, 0.710) into sRGB gives a green leaning blue, where the old tint was symmetric.
Shader presets
shaders/amstrad/— CTM644, GT65, CM14, each the stock 11-passcrt-guest-dr-venomchain with a horizontal-bandwidth pass of ours in front.That pass exists because
h_sharpprovably cannot do the job: its kernel isexp2(-h_sharp * w^2), so even at the floor of 1.0 a tap three texels out weighs ~0.002 — reach under ±1.5 texels. A Mode 0 pixel is 4 texels wide here. On real hardware Mode 0 is barely distinguishable from Mode 1; without this it renders as clean fat blocks. Sugarbox's own CRT shaders do channel masks only, with no horizontal filtering at all.Tube data from the service manuals; stripe pitch, deflection angle and bandwidth are not in them, and the files say inline which numbers are measured and which are era-typical.
amstrad-monitor-cm14.glslpstates outright that it is optically identical to the CTM644 preset — nothing found distinguishes the two tubes visually.Tests and CI
SugarLibRetro/tests/, opt-in via-DSUGARLIBRETRO_BUILD_TESTS=ON(default OFF so the REG-Linux package and the libretro buildbot never configure googletest). The gun math had to move intodisplay_geometry.hto be testable at all —libretro.cppis 3891 lines of statics and frontend callbacks.Mutation-verified: restoring the old formula fails
GunYIsAlwaysEvenand nothing else. Note "every displayed line is reachable" passes with the bug present — parity is the invariant that catches it.GitHub Actions on Ubuntu and Windows: configure, build, ctest, upload the core.
What is verified, and what is not
Verified locally on Linux: geometry 800x280 before/after the refactor, 7/7 tests, the workflow's exact commands, artifact at the expected path, all three presets loading (
Loaded 12 program(s)).Not verified: the Windows leg and the Actions runners. This PR is their first run.
Three CMake traps are handled:
cmake_minimum_required(3.0)would fail configure outright on CMake 4.x runners (bumped to 3.16);enable_testing()must be top-level;gtest_force_shared_crtmust be forced OFF before adding googletest, since this project rewrites/MDto/MTand MSVC would fail with LNK2038. zlib's own 18 tests are suppressed — they appeared the momentenable_testing()existed.Depends on nothing in CPCCore; the cartridge-bank work is separately at Tom1975/CPCCore#49.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HnVjbxoLEetx7Kgu68pUwy