Table of Contents
- Porting Clock Signal to New Platforms
- What a New Port Must Do
- Obtaining a Machine
- 'Dynamic' Machines
- Aside on: 'CRT Machines'
- Providing Time
- Providing Keyboard Input
- Providing Joystick Input
- Obtaining Audio Output
- Obtaining Video Output
- Observing 'Activity'
- Runtime Configuration
- Providing Time Revisited: the Best-Effort Updater
- Inserting Different or Additional Media
Porting Clock Signal to New Platforms
Disclaimer: this guide discusses various internal implementation details, which means that it contains information that might change. I will attempt to include commentary on how stable all discussed mechanisms have empirically proven to be, and what future plans currently are.
What a New Port Must Do
Machines provide a common interface for connection to the outside world.
A new port should:
- have some means to collect input and forward it to an emulated machine;
- provide somewhere to which an emulated machine can forward audio and video output; and
- provide time updates to the emulated machine.
At present only keyboard and analogue and digital joystick input is defined; in the future I intended to support mouse-type devices, paddles, etc.
Suggested Point of Entry for Code
See [https://github.com/TomHarte/CLK/blob/master/OSBindings/SDL/main.cpp|the SDL target]. That single file is the entirety of it. No further SDL-specific code exists in the repository and no additional changes were required for the SDL port to operate.
Obtaining a Machine
... based on a piece of media
Step one: run the static analyser on the media. Call Analyser::Static::GetTargets(<file name>)
. It'll return a list of all the machine configurations that might be correct for that piece of media. Most of the time it'll be exactly one, but it may be more where static analysis didn't prove sufficient.
If no potential targets are returned, the emulator does not know how to provide a machine for that piece of media.
Step two: construct a machine to run those targets by calling Machine::MachineForTargets(targets, rom_fetcher, error)
. A single machine contains all potential targets. Only the one believed most likely to be correct will be visible, with dynamic analysis calculating that on a rolling basis.
The rom_fetcher
is a lambda that accepts: (i) the name of a machine (e.g. 'Electron'); and (ii) a list of required ROM file names for that machine (e.g. 'os100.rom'). It should return the contents of those ROMs where they can be found. It is up to the specific target to decide how to obtain those ROM contents. The Mac version checks the app bundle's resources folder, the SDL version checks some well-defined global paths if no command-line option was present, hypothetical other versions might obtain the ROMs from the internet or provide versions built into the executable, or take any other step they require.
error
will be filled in with any error that was encountered in creating the machine.
... without a piece of media
At present there is no reflective way to achieve this; this is considered a significant flaw and is why this feature is currently available on the Mac only. Hesitation has arisen because it intersects with a bigger question about maintainably providing reflective data structures in C++, which would pair off with any future work on debugging capabilities and possibly consume the current means for providing runtime options (see below).
In Analyser/Static/StaticAnalyser.hpp
you'll see the definition for Analyser::Static::Target
, which is the base class for the targets you can supply to Machine::MachineForTargets
. In practice most machines define a custom subclass of this that adds the machine specific static configuration (e.g. how much RAM, which region — things which a real machine can't change at runtime). E.g. Analyser/Static/MSX/Target.hpp
defines an MSX target to be one that adds: (i) a boolean to indicate whether a disk drive is present; (ii) a string 'loading command'; and (iii) a region.
So, simply instantiate an appropriate Target
, and supply it in a single-item vector to Machine::MachineForTargets
.
(Aside: the loading command pattern is reasonably common but not universal. It's a string that, if specified, the machine will automatically type after booting up. It's nonsensical for machines like the Atari 2600, ColecoVision and Master System, but useful for machines like the Electron (when loading from tape) or the Vic-20 where launching a particular piece of media requires a command to be executed, sometimes specific to that particular instance of that type of media. So it is by this field that the static analyser can provide the command to be loaded.)
'Dynamic' Machines
A dynamic machine is one that provides a discoverable interface; the DynamicMachine
returned by Machine::MachineForTargets
provides a mechanism for answering questions such as 'Does this machine have a keyboard?', 'Does it have any status LEDs?', 'Does it produce sound?' through runtime discovery. It is specifically equivalent to dynamic_cast
.
See Machines/DynamicMachine.hpp
for the different types of interface a dynamic machine may vend. They're discussed in more detail below.
Aside on: 'CRT Machines'
Historically, the common denominator of all implemented machines was that they used a CRT for output, and therefore provided an instance of the internal CRT emulation. From there comes the idea of a CRTMachine
. The name became outdated in March 2019, and this interface is likely to change soon.
Providing Time
Emulated machines are advanced via the CRTMachine
interface via run_for(Time::Seconds)
. Time::Seconds
is a floating-point type.
This interface is specified in real time rather than e.g. clock cycles because if dynamic analysis is ongoing then it might involve machines of different clock rates.
Providing Keyboard Input
Not all machines have a keyboard; a machine holder must therefore check that the KeyboardMachine::Machine
vended by a dynamic machine is non-null.
Each keyboard machine also provides both:
- a default mapping from PC-style keyboards to that machine; and
- a machine-specific set of keys.
If you don't want to write specific machine-by-machine code, use the former. Use get_keyboard
on the machine to get an instance of Inputs::Keyboard
. See its enum
for the list of keys that approximates a PC layout. Use set_key_pressed
for announcing key up and key down, and reset_all_keys
if useful (in practice, it can be an easier way to deal with task switching on a host machine).
If the host is interested, is_exclusive
and observed_keys
can be used to distinguish between a keyboard that is a full keyboard (such as that on a micro) and one that is just a small number of keys (such as a games console with some buttons on the main unit). This can be useful when allowing somebody to control a joystick-driven machine that also has some keys on it with their keyboard — it allows the host to determine that they can use most of the keyboard for joystick input and to learn which keys it cannot use for joystick emulation.
Providing a target for pasteboard/clipboard text
KeyboardMachine::Machine
s also offer type_string
. You can pass a std::string
directly to it, to instruct the machine to type that phrase next. It is by this route that ports can implement support for pasting text into a machine.
No mechanism is yet provided for copying.
Providing Joystick Input
If a dynamic machine vends a joystick machine, that machine can provide you with a vector of the joysticks that machine currently believes it has plugged in. The first thing in the array will be the joystick most normally associated with the first player, the second will be that associated with the second, etc. Each thing in the vector will be an instance of Inputs::Joystick
.
Each joystick provides a list of inputs; broadly inputs are either an axis or a button. More specifically, they can be a full axis or half an axis (e.g. left and right are half axes; 'x' is a whole axis), and buttons can be either fire buttons or keys. Keys tend to be peripheral to main play (e.g. the number pad on a ColecoVision) whereas fire buttons are usually primary inputs.
Both set_input(input, bool)
and set_input(input, float)
are provided for pushing state to a joystick.
A host needn't worry about digital versus analogue input, or axes versus half-axes if it doesn't want to. Correlated inputs will be converted automatically — e.g. if the user has only an analogue stick then just providing its input as two analogue full axes will cause the proper digital half-axes to be set on machines that accept only digital input, and this is exactly the behaviour that both the Mac and SDL ports currently rely upon.
Commentary: I'm still not entirely sold on the button/key distinction and how well it would map to all platforms. Some tweaks may occur here.
Obtaining Audio Output
If a CRTMachine
(see caveats on this class above) produces audio, it will provide an instance Outputs::Speaker::Speaker
.
The port should use get_ideal_clock_rate_in_range
to query what audio output frequency it would most ideally use within a given range, allowing for the underlying machine. Some machines include low-pass filters that make higher rates unnecessary. Nevertheless, set_output_rate
allows the port to nominate any audio generation rate that is convenient for it, along with the packet size for audio (e.g. whether you want to receive 512 samples at a time, or 64, or 2048, or 115, or any other number).
It should then register a speaker delegate via set_delegate
. The delegate will receive a call-out (i) any time a new packet of samples is ready; and (ii) in an advisory capacity every time the underlying clock rate that is causing audio to be generated changes. Notifications via (ii) can be ignored if desired, or can be used to trigger a renegotiation of the output rate.
New samples will be delivered on an arbitrary thread, almost certainly distinct from the one you called run_for on. This is because audio generation happens on a separate thread from machine emulation.
Commentary: so far, only mono audio is supported. Expect some minor modifications to the delegate protocol here.
Obtaining Video Output
Via CRTMachine
(see above), the host can provide a ScanTarget
. A scan target is something that receives 'scans', i.e. individual runs of one-dimensional graphical output, on a continuous 2d canvas. As per the CRTMachine
moniker, historically these always emanated from the CRT emulation.
See Outputs::ScanTarget
for the full scan target interface. In approximate terms, you're going to receive both some information about decoded sync pulses (horizontal and vertical) plus the 2d start and end points of individual scans, and some negotiation over storage for the 1d data that goes inside them — the scan target is responsible for management of data storage, to allow it to be pushed into a GPU/CPU shared memory area on machines that support that. Things like colour clock phase are provided per scan and at pulse boundaries.
It also receives various modal properties, including the type of data that is being forwarded (various forms of pure luminance, luminance plus phase or full-on RGB), the various scales at play, and the colour space and gamma.
Both current ports opt to use the same pre-packaged ScanTarget, which bundles all of that information off to OpenGL, and provides proper composite and S-Video decoders. Commentary: and further scan targets are likely to arrive by factoring buffer management out of there.
The OpenGL ScanTarget
See Outputs/OpenGL/ScanTarget.hpp
. This is a scan target suitable for provision to an emulated machine that will collect the output it is given and then provides (i) an update method, to process as much information as it has recently received; and (ii) a draw method, to draw its current state to the display.
It is thread safe; either or both of update and draw can be called on any thread, whether emulation is currently ongoing or not.
The two things are distinct since it is useful for some ports to be able to redraw the current state without the costs of mutating it, e.g. when interactively resizing a window.
This particular scan target currently requires desktop OpenGL 3.2.
Make sure that when you instantiate your instance of the OpenGL scan target, or ask it to draw or update, that there is an active OpenGL context as those calls all result in GPU activity.
Observing 'Activity'
This is a nice-to-have for a port, but very optional. Some machines provide activity LEDs, e.g. for drive activity or for caps lock. If the dynamic machine can vend an activity source then an activity observer can be posted to it. That observer will immediately receive an enumeration of LEDs and drives attached to the system, and will subsequently receive notification of both audible drive events (i.e. activity a user could observe) and LED changes.
Runtime Configuration
Machines that provide further runtime configuration options — e.g. if they can support both RGB and composite outputs, which of those is connected — provide that information reflectively via Configurable::Device
. This is the route that the SDL port uses for determining command-line options and then for forwarding user selections.
The Mac port uses the same mechanism for forwarding user selections but the relevant configuration dialogues are hand-designed. I'm on the fence as to whether that's actually a bad idea.
As per comments elsewhere, I'm still considering the ramifications of the type of reflection used here, which requires quite verbose coding effort. I think I'm bending towards other options before rolling this out to static configuration and, one day, possibly debugging.
Providing Time Revisited: the Best-Effort Updater
The best-effort updater, Concurrency/BestEffortUpdater.hpp
is a helper class currently used by both the Mac and SDL ports, though it's entirely optional. It provides a thread-safe update
method that can be called to request that 'more' emulation occurs. It will tell a delegate how long to run emulation for, but it will do so only if a call to the delegate isn't already happening.
So the scheme used by both ports is simply to call update
any time that more output — video or audio — is required, on whichever thread it learns that.
Inserting Different or Additional Media
Use Analyser::Static::GetMedia
to obtain an instance of Analyser::Static::Media
for the file. E.g. if it's a single disc image, the piece of media returned will be a single disc.
Grab the dynamic machine's media_target()
if it provides one. Not all machines do — for example, there's nothing you can insert into a Master System once it's running. Call insert_media
on the media target with the media object that the static analyser returned. Its return result will tell you whether the insertion was successful.
Commentary: the media target interface doesn't currently allow the caller to be more specific than merely 'insert this media', e.g. it isn't possible for a port to allow the user to select which drive they want to insert a disc image into. This primarily a relic of my desire not to provide a complicated UI. It's something I'd like to correct, however.
Analyser::Static::GetMedia
will simply determine the type of the file and return the proper disk(s), tape(s) and/or cartridge(s). That piece of media is not bound to a specific type of machine — e.g. it's possible to insert any disk into any machine that has a disk drive, it just won't necessarily be something the drive can read.