Skip to content

Add Huc6280 Support - #134

Merged
mre merged 7 commits into
masterfrom
huc6280-variant
Jun 26, 2026
Merged

Add Huc6280 Support#134
mre merged 7 commits into
masterfrom
huc6280-variant

Conversation

@mre

@mre mre commented Jun 13, 2026

Copy link
Copy Markdown
Owner

This adds support for the Hudson Soft / NEC HuC6280, the CPU powering the NEC TurboGrafx-16 / PC Engine from 1987.

Apart from the additional instructions, it adds a 21-bit address space. Addressable memory starts at $2000, which is why I introduced support for relocating the zero-page. (Other variants are unaffected.)

The T (memory-operation) flag, which redirects ORA/AND/EOR/ADC and friends to operate on memory instead of the accumulator is not implemented yet. It's a bigger change, that will probably touch the other variants, so I left that out for now.

I found https://github.com/SingleStepTests/huc6280, which provides Mesen-generated tests. It's in JSON format, which is why I added a little translator to be able to run these tests. Found a few bugs this way. The corpus is quite large, which is why I didn't include it in the PR. We can consider changing that if worthwhile.

TODO (for future PRs)

I checked what's missingand it's quite a lot, but it's probably okay to brush up this PR and merge what we already have. (Well, except for the mising Bus support, which I want to add before merging.)

  • Sound (the 6-channel PSG), the programmable timer, and the 8-bit I/O port are not emulated (like all I/O they're left to the Bus implementation I think).
  • The SET (T-flag) prefix instruction is not implemented, so the T-flag memory-operand mode for arithmetic/logic ops is unsupported.
  • CSL/CSH (clock speed select) are no-ops; the emulator tracks per-instruction cycle counts, not wall-clock timing, so the 1.79/7.16 MHz switch has no effect. I think that's fine. Don't think that clock cycles should change when the clock speed gets changed.
  • ST0/ST1/ST2 just write the immediate to fixed memory offsets ($0000/$0002/$0003) and rely on a Bus implementation to intercept them as VDC port writes; there is no actual VDC (Video Display Controller).
  • The CPU core only ever emits 16-bit logical addresses; MMU logical-to-physical translation (MPR0-MPR7) is provided as a helper but the actual bank mapping must be done by the Bus (the integration tests use a translating PhysBus for this, but maybe we can remove that).
  • Block-transfer instructions block until completion in a single step; their per-byte cost (6 cycles/byte) is added manually at execution time rather than coming from the cycle table, and interrupt behavior mid-transfer is not modelled. I am absolutely uncertain what to do with this one. It feels weird to manually add the cycle costs. Will have to think about that.
  • The HuC6280-only opcodes share the global base_cycles table and execute match arms with all other variants; they're gated only by per-variant decode, so nothing structurally prevents another variant from reaching them if its decoder were changed to emit those instructions, but I think that's okay since adding another level of abstraction is just adding complexity for little gain.
  • The MPR registers exist on every variant's Registers struct (zeroed and unused for non-HuC6280 parts), so the type doesn't distinguish HuC6280 state from the rest. We discussed moving more of that functionality into the Bus. Not sure if we can get rid of those registers entirely, though. I was considering to move the register definition into the variant trait, but it would add complexity and the number of unused registers per variant is pretty limited anyway.
  • Only opcodes covered by the SingleStepTests suite are validated; untested edge cases (e.g. flag effects on rarely-used combinations) may differ from hardware. I am planning to do a larger test with some actual test roms (TurboGrafx-16 games) when I get the chance.

@mre
mre requested a review from omarandlorraine June 13, 2026 15:00
@mre
mre force-pushed the huc6280-variant branch from 68150db to 8eefff5 Compare June 14, 2026 10:59
Comment thread src/registers.rs
stack_pointer: StackPointer(0), // Real hardware: random value on power-on
program_counter: 0, // Set by reset vector in practice
status: Status::default(),
mpr: [0; 8], // HuC6280 MMU registers; unused by other variants

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to move this into the Bus trait somehow? That would be cleaner. But then, I have no idea how this works or what it needs to do.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... maybe? But then again, I believe that they are actual registers. See here:

Software running on the HuC6280 operates on 16-bit memory addresses, same as 6502 software, but the HuC6280 has a builtin MMU that expands the physical address space from 16-bit to 21-bit (2 MB address range). It’s extremely simple as far as MMUs go: it splits the 16-bit logical address space into eight 8 KB pages, and each page has its own 8-bit MPR (Memory Page Register) that maps it directly to an 8 KB physical page. It’s very similar to the memory-banking mappers seen in many NES / Game Boy / Master System / Game Gear cartridges, only it’s built into the CPU, so game cartridges don’t need to include their own mapper hardware (though one game still does due to its massive ROM size).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it in there for now.

Comment thread src/registers.rs
/// For every non-HuC6280 variant the mapping registers are zero, so this is
/// the identity mapping within the low 64 KB.
#[must_use]
pub const fn physical_address(&self, logical: u16) -> u32 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Can the MMU functionality move into an implementation of the Bus trait? (that would include this function, physical_address, the mpr registers and the handling of relevant opcodes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx, I can look into that.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we have to shadow the MPR registers because translation runs inside bus.get_byte(), which isn't idea. The MPRs live in the cpu, so the bus needs to copy. What's worse is that we have to create a copy whenever TAM runs...

I don't think there's an easy solution here.

We could try moving MPRs into the bus (then the bus needs MMU-specific trait methods + HuC6280 can no longer run on a plain Memory bus), or we try moving translation into the CPU (then Bus has to take 21-bit physical addresses, which pollutes the generic core for one variant).

Both of those make the generic core worse to fix one variant. So I'd say the current tradeoff (generic u16 Bus, MMU as this thin integrator-side adapter) is the more idiomatic way.

But there are still ways to make it more ergonomic. I noticed that there's quite a bit of redundancy, and I'll push a commit in a sec to fix that.

Also voting to add set_mapping_register to the Bus trait. The default implementation does nothing, which is correct for all variants without an MMU. I will add this method, but we can remove it again if it doesn't carry its own weight.

Comment thread src/lib.rs
Comment thread .gitignore
Comment thread src/lib.rs
@omarandlorraine

Copy link
Copy Markdown
Collaborator

Good job!

My only remaining question would be the red CI / lint. Oh, and I guess the version number needs to be bumped.

@mre

mre commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

To properly handle HuC6280's IRQ vector multiplexing with a cleaner design, I'm reconsidering how the vector abstraction should work. Right now the Variant trait scatters individual vector methods as static functions, but for a CPU with multiple IRQ sources that need priority resolution, I don't think that's enough. Maybe have to do some refactoring. 🤔

@mre

mre commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

I moved the interrupt handler to the bus. It's the only place that can see the runtime state needed to pick the correct IRQ vector. This is a more accurate abstraction than the previous version.

The previous commit put irq_vector() on the Variant trait alongside reset_vector()/nmi_vector()/brk_vector(). That was inaccurate: a Variant is a zero-sized marker type, so it can't see the runtime state (which sources are pending and unmasked) that the HuC6280's on-chip interrupt controller uses to pick the IRQ vector. The previous version could only ever return a single static address.

Now, I split the responsibility along the real hardware boundary:

  • Variant contains reset_vector(), nmi_vector(), brk_vector(). They never vary at runtime.
  • Bus::irq_vector(&mut self) -> u16, which defaults to $FFFE. It is only being consulted while an IRQ is pending.

This way, we can now express the TIQ > IRQ1 > IRQ2 priority by overriding the method.

mre added 2 commits June 21, 2026 15:58
…n see the runtime state needed to pick the correct IRQ vector. This is a more accurate abstraction than the previous version.

The previous commit put `irq_vector()` on the `Variant` trait alongside `reset_vector()`/`nmi_vector()`/`brk_vector()`. That was inaccurate: a `Variant` is a zero-sized marker type, so it can't see the runtime state (which sources are pending and unmasked) that the HuC6280's on-chip interrupt controller uses to pick the IRQ vector. The previous version could only ever return a single static address.

Now, I split the responsibility along the real hardware boundary:

- `Variant` contains `reset_vector()`, `nmi_vector()`, `brk_vector()`. They never vary at runtime.
- `Bus::irq_vector(&mut self) -> u16`, which defaults to `$FFFE`. It is only being consulted while an IRQ is pending.

This way, we can now express the TIQ > IRQ1 > IRQ2 priority by overriding the method.
@mre

mre commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

Fixed a small correctness bug: the core didn't advance cycles while in WAI/STP/JAM or on an undecodable opcode, so any host using the cycle delta for pacing deadlocks (e.g. "WAI-for-vblank" games). I made single_step advance the clock in those branches and exposed the wait state.

@mre

mre commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

Done with my refactorings. I think this is ready for another round of reviews / mergeable.

@mre

mre commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

I will go ahead and merge this. Should be relatively clean and I am planning to release my little Turbografx-16 emulator based on this work. We can make more tweaks in the future, but so far I didn't find any issues with the code when trying it on a few games. See the other repo.

@mre
mre merged commit 8936d1e into master Jun 26, 2026
7 checks passed
@mre
mre deleted the huc6280-variant branch June 26, 2026 15: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.

2 participants