Skip to content

Native CPC scanlines, Amstrad monitor shader presets, lightgun fix, tests and CI - #1

Open
rtissera wants to merge 11 commits into
masterfrom
native-geometry-and-monitor-presets
Open

rtissera wants to merge 11 commits into
masterfrom
native-geometry-and-monitor-presets

Conversation

@rtissera

Copy link
Copy Markdown
Owner

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 Monitor writes one CPC scanline into every other buffer row — GetVideoBuffer(y) returns row 2y. That is the upstream convention, not ours: CPCCore's own UnitTests/Display.cpp:52 does the same. Filling the odd rows is the display implementation's job, which is what IDisplay::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-geom computes ilfac = 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+, with interlace_detect on 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-pass crt-guest-dr-venom chain with a horizontal-bandwidth pass of ours in front.

That pass exists because h_sharp provably cannot do the job: its kernel is exp2(-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.glslp states 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 into display_geometry.h to be testable at all — libretro.cpp is 3891 lines of statics and frontend callbacks.

Mutation-verified: restoring the old formula fails GunYIsAlwaysEven and 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_crt must be forced OFF before adding googletest, since this project rewrites /MD to /MT and MSVC would fail with LNK2038. zlib's own 18 tests are suppressed — they appeared the moment enable_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

rtissera and others added 11 commits September 19, 2026 21:30
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
rtissera force-pushed the native-geometry-and-monitor-presets branch from 63920a2 to c3ed098 Compare September 19, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant