A libretro core

Runs izapple2 inside RetroArch and the other libretro frontends. It is built
as a C shared library with cgo, so it needs a C toolchain, and it is not part
of the releases.

The frontend owns the frame timing, calling retro_run once per frame, so the
core advances the machine with RunCycles and hands over one video frame and
the matching audio samples on the thread it was called on, with no
goroutines. A frame is 17030 cycles. While a drive asks for fast mode it runs
sixteen frames worth, or loading a disk takes half a minute.

Kept small on purpose: no super hi-res, no Videx video, no RGB card and no
savestates. What is left is generated as 560x192, so the framebuffer is a
fixed 564x192 and the geometry never changes. The cards that produce the
other modes are taken out of the slots.

Diskettes are swapped from the menu through the extended disk control
interface, with the list coming from an M3U playlist, and the saves go to the
save directory of the frontend as overlays.

Go cannot call the function pointers the frontend hands over, so shim.c keeps
them and reaches them through plain C functions, and the callbacks the core
provides go the other way through it. Four entry points take a const pointer,
which cgo cannot generate, so those are wrapped there as well.

The default machine is the new gaming model, an enhanced //e with a
Mockingboard, no mouse and no VidHD, which also serves the command line.

See doc/libretro.md to use it, frontend/a2libretro/README.md for how it works.
This commit is contained in:
Iván Izaguirre authored and Iván Izaguirre committed 2026-08-07 08:53:22 +02:00
1 parent 9e11107e4f
commit b98cf17696
25 files changed
+10313 -13

No files matched your search

+4
View File
@@ -12,6 +12,10 @@ frontend/*/*.zip
frontend/*/images
frontend/a2fyne/a2fyne
frontend/headless/headless
frontend/a2libretro/izapple2_libretro.so
frontend/a2libretro/izapple2_libretro.dylib
frontend/a2libretro/izapple2_libretro.dll
frontend/a2libretro/izapple2_libretro.h
frontend/*/snapshot.gif
frontend/*/snapshot.png
izapple2headless
+4
View File
@@ -28,6 +28,10 @@ go build .
# Build console frontend
cd frontend/a2console
go build .
# Build the libretro core, needs a C toolchain
cd frontend/a2libretro
make
```
### Install Dependencies
+6
View File
@@ -186,6 +186,12 @@ Line:
```
### RetroArch and other libretro frontends
izapple2 can also be built as a libretro core, to run inside RetroArch, Lakka,
Batocera, RetroPie and the rest. See [doc/libretro.md](doc/libretro.md) for how
to build it, install it and what it supports.
### Command line options
See [doc/command_line.md](doc/command_line.md) for a complete guide on command line configuration.
+13
View File
@@ -0,0 +1,13 @@
# A machine set up for playing games. It is the default of the libretro core.
#
# An enhanced Apple //e, the model that runs most of the Apple II catalogue,
# with the Mockingboard for the games that play their music through it and the
# accelerator that the game launchers use to load faster.
#
# There is no mouse, games do not need one. There is no VidHD either: the
# libretro core does not render super hi-res, and taking the card out keeps the
# software from switching to a mode that would not be shown.
name: Apple //e for gaming
parent: 2enh
rgb: false
s2: empty
+22 -13
View File
@@ -475,6 +475,25 @@ func expandSlotConfiguration(configString string) (string, error) {
return configString, nil
}
// expandSlotConfigurations expands the partial slot configurations of every
// slot (e.g. "-s4 disk.dsk" -> "-s4 diskii,disk1=disk.dsk")
func expandSlotConfigurations(c *configuration) error {
slotParams := []string{confS0, confS1, confS2, confS3, confS4, confS5, confS6, confS7}
for _, slotParam := range slotParams {
if c.has(slotParam) {
slotConfig := c.get(slotParam)
expandedConfig, err := expandSlotConfiguration(slotConfig)
if err != nil {
return fmt.Errorf("error expanding slot configuration for %s: %w", slotParam, err)
}
if expandedConfig != slotConfig {
c.set(slotParam, expandedConfig)
}
}
}
return nil
}
func getConfigurationFromCommandLine() (*configuration, error) {
models, configuration, err := loadConfigurationModelsAndDefault()
if err != nil {
@@ -498,19 +517,9 @@ func getConfigurationFromCommandLine() (*configuration, error) {
configuration.set(f.Name, f.Value.String())
})
// Expand partial slot configurations (e.g., "-s4 disk.dsk" -> "-s4 diskii,disk1=disk.dsk")
slotParams := []string{confS0, confS1, confS2, confS3, confS4, confS5, confS6, confS7}
for _, slotParam := range slotParams {
if configuration.has(slotParam) {
slotConfig := configuration.get(slotParam)
expandedConfig, err := expandSlotConfiguration(slotConfig)
if err != nil {
return nil, fmt.Errorf("error expanding slot configuration for %s: %w", slotParam, err)
}
if expandedConfig != slotConfig {
configuration.set(slotParam, expandedConfig)
}
}
err = expandSlotConfigurations(configuration)
if err != nil {
return nil, err
}
// Process positional filenames (e.g., "program disk.dsk")
+1
View File
@@ -313,6 +313,7 @@ The available pre-configured models are:
cpm65: Apple //e with CPM-65
desktop: Apple II DeskTop
dos32: Apple ][ with 13 sectors disk adapter and DOS 3.2x
gaming: Apple //e for gaming
pascal: Apple //e with Apple Pascal 1.3
prodos: Apple //e Prodos
swyft: swyft
+329
View File
@@ -0,0 +1,329 @@
# Using izapple2 as a libretro core
izapple2 can be built as a [libretro](https://www.libretro.com/) core, so the
emulator runs inside RetroArch and the other libretro frontends instead of in
its own window. Your Apple II disks then sit in the same library as the rest of
your collection, with the same controller setup, video filters and shaders.
The core is not part of the [releases](https://github.com/ivanizag/izapple2/releases),
you have to build it from source. It is a small build, the instructions are below.
If you just want to run Apple II software on your computer, the regular
`izapple2sdl` executable is easier and does more. Use the core if you already
live in RetroArch.
## Building
The core is a shared library built with cgo, so besides Go you need a C
compiler. You do not need SDL2.
### Linux
``` terminal
git clone https://github.com/ivanizag/izapple2
cd izapple2/frontend/a2libretro
make
```
This produces `izapple2_libretro.so`.
### macOS
``` terminal
git clone https://github.com/ivanizag/izapple2
cd izapple2/frontend/a2libretro
make
```
This produces `izapple2_libretro.dylib`.
### Windows
CGO needs a gcc compiler. Install
[mingw-w64](http://mingw-w64.org/doku.php/download/mingw-builds) and run:
``` terminal
git clone https://github.com/ivanizag/izapple2
cd izapple2\frontend\a2libretro
go build -buildmode=c-shared -o izapple2_libretro.dll .
```
## Installing the core
Copy the library you built into the cores directory of your frontend.
### RetroArch
| System | Cores directory |
| --- | --- |
| Linux | `~/.config/retroarch/cores` |
| macOS | `~/Library/Application Support/RetroArch/cores` |
| Windows | `cores` inside the RetroArch folder |
If you are not sure, RetroArch shows the path in
*Settings > Directory > Cores*.
Also copy `izapple2_libretro.info`, next to the sources in
`frontend/a2libretro`, into the info directory, shown in
*Settings > Directory > Core Info*. It is what makes RetroArch show the core
name, the supported extensions and the list of what it can and cannot do
instead of treating it as an unknown core. The core works without it, but the
content dialog will not filter by extension.
Then load it with *Load Core > Install or Restore a Core*, or pick it from
*Load Core* after restarting RetroArch.
The core is not in the RetroArch online updater, so it will not be downloaded
or updated for you, and the playlist scanner will not recognize Apple II disks
by itself. Load them by hand with *Load Core* followed by *Load Content*.
### Lakka, Batocera, RetroPie, Recalbox
All of these run RetroArch underneath, so the core works the same way. Copy the
`.so` you built for that machine's architecture into its cores directory
(`/tmp/cores` or `/usr/lib/libretro` on Lakka,
`/userdata/system/configs/retroarch/cores` on Batocera,
`/opt/retropie/libretrocores` on RetroPie) and select it as the core for your
Apple II games.
Be careful to build for the right architecture. A core built on your x86 laptop
will not load on a Raspberry Pi; you need to cross compile with the toolchain of
the target, setting `CC`, `GOOS` and `GOARCH`.
### Other frontends
Anything that loads libretro cores works: Kodi's game add-ons, Emulation
Station DE, Provenance, and the rest. Nothing in the core is specific to
RetroArch.
## Running software
### With no disk
Load the core and start it with no content. You get an enhanced Apple //e
booting DOS 3.3, ready for Applesoft BASIC. The ROMs and the DOS 3.3 diskette
are built into the core, there is nothing else to download.
In RetroArch this is *Load Core*, then *Start Core*.
### With a disk
Use *Load Content* and pick your disk image. These extensions are recognized:
| Extension | What it is |
| --- | --- |
| `dsk`, `do`, `po`, `nib`, `woz` | 5 1/4 diskettes |
| `2mg`, `hdv` | 3.5 disks and hard disks |
| `wav` | cassette tape recordings |
| `zip`, `gz` | any of the above, compressed |
| `m3u` | a playlist for a game on several diskettes, see below |
The core decides where to put the file the same way the command line does: a
diskette goes into the Disk II in slot 6, a hard disk image into a SmartPort
card, and a WAV into the cassette input.
### Games on several diskettes
Plenty of Apple II games ask you to insert disk 2 partway through. List the
diskettes in an `.m3u` playlist, a plain text file with one image per line, and
load that instead of a single disk:
``` terminal
Bard's Tale - Disk 1, Side A.woz
Bard's Tale - Disk 1, Side B.woz
Bard's Tale - Disk 2, Side A.woz
```
Relative paths are taken from the folder of the playlist, and lines starting
with `#` are ignored. The disks are named in the menu after their file names.
When the game asks for another disk, open *Quick Menu > Disk Control*, eject
the current disk, pick the one you need and insert it. RetroArch remembers
which one you were on the next time you load the playlist.
Only the first drive of the controller in slot 6 is swapped, which is what
games ask for.
### Saved games
Games that save write to the disk, so the core keeps your collection out of it.
The changes never reach the image you loaded: they go to an overlay file in the
save directory of the frontend, shown in *Settings > Directory > Saves*, named
after the disk with `.ovl` added.
An overlay only holds the parts the game actually wrote, not a copy of the
disk, so it is small: a few kilobytes for a diskette, and for a 32 Mb hard disk
only the blocks that were saved. Loading the game again puts them back on top
of the image and you are where you left off.
Because the image is only ever read, this also works for the disks that could
not be written at all before: the ones inside a zip or a gzip, the ones loaded
from an URL, and the DOS 3.3 built into the core. You can save to all of them
now.
Diskettes and hard disks both work this way, so a ProDOS collection on a HDV or
2MG is left untouched as well.
An overlay carries a checksum of the image it was made from, so it is never
applied to a different disk that happens to have the same name. If you get an
error saying the overlay belongs to another image, delete the `.ovl` file.
To start over from a clean disk, delete its `.ovl` file from the save
directory.
WOZ and NIB diskettes are read only by nature, and games on them cannot save.
This is not specific to the core. Any izapple2 frontend gets the same with the
`-saveDir` command line option, and each disk card can name a directory of its
own, see
[Keeping the Disk Images Unmodified](command_line.md#keeping-the-disk-images-unmodified).
### Speed
Disk access automatically runs faster than the real machine, as it does in the
other izapple2 frontends, so a DOS 3.3 boot takes about two seconds instead of
half a minute. The sound breaks up while this happens. That is expected.
RetroArch's own fast forward and slow motion work as usual on top of that.
## Core options
In *Quick Menu > Core Options*:
| Option | Values | Meaning |
| --- | --- | --- |
| Machine model | `gaming` (default), `2`, `2e`, `2enh`, `2plus`, `base64a`, `basis108`, `cpm`, `cpm3`, `cpm65`, `desktop`, `dos32`, `pascal`, `prodos`, `swyft` | The machine to emulate, the same models as the `-model` command line flag |
| Monitor | `color` (default), `green` | An NTSC colour television or a green phosphor monitor |
Changing the model takes effect on the next **Reset**, which cold boots the
machine. The monitor applies immediately.
### The default machine
`gaming` is an enhanced Apple //e set up for playing, the model that runs most
of the Apple II catalogue:
- 128Kb of RAM, expanded to 8Mb with a RAMWorks card, which the big game
collections use
- A Mockingboard sound card in slot 4, for the games that play their music
through it
- A FASTChip accelerator in slot 3, which the game launchers use to load faster
- A Disk II controller in slot 6, with DOS 3.3 in the first drive
- No mouse, and no VidHD card, since the core does not render super hi-res
Loading a hard disk image adds a SmartPort card in slot 7 by itself.
The other models are the regular izapple2 ones, see
[command_line.md](command_line.md) for what each of them is. They are all
usable, but the video cards the core cannot render are taken out of them too.
## Controls
### Keyboard
The core reads your keyboard directly, so just type. Most keys do the obvious
thing.
| Key | Apple II |
| --- | --- |
| Return | Return |
| Left arrow, Backspace | Left arrow |
| Right, Up, Down arrows | The matching arrows |
| Escape | Escape |
| Tab | Tab |
| Delete | Delete |
| Ctrl + letter | The control code, for example Ctrl-C to break |
| Left alt or option | Open apple |
| Right alt or option | Closed apple |
Reset is the frontend's own Reset, in *Quick Menu > Restart*, or whatever you
bound the reset hotkey to. Note that RetroArch grabs some keys for its own
hotkeys, F1 by default for the menu; rebind them in
*Settings > Input > Hotkeys* if they get in the way.
### Gamepad
The first controller port drives the Apple II paddles:
| Control | Apple II |
| --- | --- |
| Left analog stick | Paddles 0 and 1 |
| D-pad | Paddles 0 and 1, for controllers with no stick |
| A | Button 0, same as the open apple |
| B | Button 1, same as the closed apple |
| X | Button 2 |
Only one joystick is emulated. The second pair of paddles always reads
centered.
## What is not supported
The core is deliberately smaller than the other frontends. If you need any of
this, use `izapple2sdl`.
- **Super hi-res graphics.** The VidHD card is removed from the machine, so the
box art of the [Total Replay](https://archive.org/details/TotalReplay)
collection does not show. Total Replay itself runs, it just looks like it does
on a machine without a VidHD.
- **Videx 80 column video**, so the `ultraterm` model is not offered and the
Videx cards are removed from the models that carry them.
- **The RGB card** and its extra graphic modes.
- **Savestates**, and therefore no rewind, no run ahead and no netplay. The
emulator has no way to save the state of the machine, so this is not something
the core can turn on.
- **The mouse.** The models with a mouse card run, but the mouse never moves.
- **Cheats**, which do not apply to a computer.
Everything else that izapple2 emulates works: the disk drives, the sound
including the Mockingboard, the language card, the 80 column card, the
accelerators and the rest.
## Video
The core hands the frontend a 564x192 image with a 4:3 aspect ratio. The
picture is not scaled, and the gaps between the scan lines are not drawn, so
that you can use whatever shader you prefer. A CRT shader such as `crt-geom` or
`crt-royale` gives a good period look on top of either monitor.
The choice of monitor is a core option and not a shader, because the two are
not the same thing. A green monitor does not just tint the picture: the Apple II
draws half width pixels on it that a colour television cannot show, so the
emulator has to render the screen differently from the start.
If the picture looks stretched, check that *Settings > Video > Scaling > Aspect
Ratio* is set to `Core Provided`.
## Troubleshooting
**The core does not appear in the list.** Use *Load Core > Install or Restore a
Core* and point at the library file. If it loads but shows up unnamed, or the
content dialog does not filter Apple II disks, the `izapple2_libretro.info`
file is not in the info directory.
**The core fails to load.** Almost always an architecture mismatch, a core
built for a different machine than the one running it. Rebuild on the target,
or cross compile with the right `CC`, `GOOS` and `GOARCH`.
**No sound during loading.** Expected while the disk is being read at speed,
see [Speed](#speed) above.
**Rewind and run ahead are greyed out.** The core has no savestates, see above.
**A game asks for disk 2, and the Disk Control menu is empty.** You loaded a
single disk image rather than an `.m3u` playlist listing all of them. Write the
playlist and load that instead, see
[Games on several diskettes](#games-on-several-diskettes).
**My saved game disappeared.** The saves live in the `.ovl` file next to the
other saves, see [Saved games](#saved-games). If you deleted it, or the frontend
is pointing at a different save directory than before, the disk loads as it came
and the saves are not there. Renaming the disk image also loses them, the
overlay is found by the name of the file.
**"the overlay belongs to another disk image".** The `.ovl` file was made from
a different image with the same name, so applying it would corrupt the disk and
the core refuses. Delete the `.ovl` file, losing what was saved in it, or put
back the image it was made from.
**A game cannot save.** It is probably on a WOZ or NIB diskette, which are read
only. Look for the same game as a DSK or PO.
+1
View File
@@ -62,6 +62,7 @@ The available pre-configured models are:
cpm65: Apple //e with CPM-65
desktop: Apple II DeskTop
dos32: Apple ][ with 13 sectors disk adapter and DOS 3.2x
gaming: Apple //e for gaming
pascal: Apple //e with Apple Pascal 1.3
prodos: Apple //e Prodos
swyft: swyft
+28
View File
@@ -0,0 +1,28 @@
# Builds the libretro core for the host platform.
#
# The core is a C shared library, so a C toolchain is needed. Cross compiling
# needs the toolchain of the target platform and the matching CC, GOOS and
# GOARCH.
UNAME := $(shell uname -s)
ifeq ($(UNAME),Darwin)
EXT := dylib
else ifneq (,$(findstring MINGW,$(UNAME)))
EXT := dll
else
EXT := so
endif
TARGET := izapple2_libretro.$(EXT)
.PHONY: all clean
all: $(TARGET)
$(TARGET):
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) .
clean:
rm -f izapple2_libretro.so izapple2_libretro.dylib izapple2_libretro.dll
rm -f izapple2_libretro.h
+111
View File
@@ -0,0 +1,111 @@
# izapple2 libretro core
A [libretro](https://www.libretro.com/) core, to run izapple2 inside RetroArch
and the other libretro frontends.
**If you want to use the core, read [doc/libretro.md](../../doc/libretro.md)**,
which covers building it for each platform, installing it in the frontends,
loading disks, the controls and what it supports. What follows are the notes on
how it is put together.
## Build
```bash
make # izapple2_libretro.{so,dylib,dll} for the host
```
Or directly:
```bash
go build -buildmode=c-shared -o izapple2_libretro.so ./frontend/a2libretro
```
`izapple2_libretro.info` goes with the library, in the info directory of the
frontend. Keep `corename`, `display_version` and `supported_extensions` in it
in step with what `retro_get_system_info` reports in `core.go`.
## How it differs from the other frontends
The other frontends let the emulation run free on its own goroutine and sample
the video and the audio whenever they need them. Here the frontend owns the
frame timing: it calls `retro_run()` once per frame, and the core has to
advance the machine by one frame, hand over the video frame and the matching
audio samples, and return. That is what `Apple2.RunCycles` is for, and it means
everything happens on the thread that calls `retro_run()`, with no goroutines
involved.
A frame is 17030 cycles, 65 for each of the 262 lines of an NTSC frame.
While a device asks for fast mode, which the Disk II does while the motor is
on, the core runs 16 frames worth of cycles per frame. Without it a DOS 3.3
boot, that spends some 37 million cycles mostly waiting for the drive, would
take more than half a minute. Sound is choppy while it lasts, as it is on the
other frontends when they run at full speed.
## What is not supported
Kept out on purpose, to keep the core small:
- **Super hi-res and the Videx 80 column video.** Every remaining mode is
generated as 560x192, four pixels more with the NTSC color filter, so the
framebuffer is a fixed 564x192 and the geometry never changes. The cards that
generate the other modes are taken out of the slots, and the `ultraterm`
model is not offered.
- **The RGB card**, that would widen the framebuffer to 644.
- **The gaps between the scan lines.** The core only uses the two screen modes
without them, `screen.ScreenModeColor` and `screen.ScreenModeGreen`, so the
image keeps its 192 lines instead of becoming four times taller, and the
frontend draws the CRT look with a shader if it wants one. The phosphor is the
other axis of the screen modes and is kept as a core option: a green monitor
shows half width pixels that no shader can recover from a colour picture.
- **Savestates.** `retro_serialize_size()` reports zero, so the frontend
disables rewind, run ahead and netplay. The emulator has no way to serialize
the machine.
- **The mouse.** A stub provider is attached because the mouse card reads its
provider without checking that there is one, so the models that carry it, like
`desktop`, would crash the frontend.
## Where the games save
The emulation writes to a disk in the file it was loaded from, which is right
on the command line and wrong in a frontend that expects the content to be left
alone. `saves.go` asks the frontend for its save directory and passes it as the
`saveDir` configuration of the machine, the same option the command line has.
It is a setting of the machine, applied when it is built, so it is given as a
configuration override rather than set afterwards: the disks are inserted while
the machine is being built. From there `setupCard` passes it to the cards that
declare a `savedir` parameter and do not name one themselves.
The overlay itself is in the storage package, `overlay.go`, and is shared with
the other frontends. The core only chooses where it lives.
## Swapping diskettes
`disk.go` implements the extended disk control interface, falling back to the
older one without labels if the frontend does not know it. The list of
diskettes comes from an `.m3u` playlist; a single disk image gives a list of
one.
The drive has no eject, so ejecting only bookkeeps and the diskette is inserted
when the tray is closed again, which is the order the frontends use. Inserting
goes through `SendLoadDisk`, which queues on the command channel that
`RunCycles` drains at the top of the next frame, so it lands on the right
thread by itself. The selected diskette is also kept in `filenames`, so a reset
boots what is in the drive rather than what was loaded first.
## The C bridge
Go can not call the function pointers that the frontend hands to the core, so
`shim.c` keeps them and reaches them through plain C functions. The keyboard
callback goes the other way: the frontend needs a C function pointer, so
`shim_keyboard_callback` forwards to a Go function exported with cgo.
Four entry points, `retro_load_game`, `retro_load_game_special`,
`retro_unserialize` and `retro_cheat_set`, take a const pointer. cgo can not
put const in the declarations it generates for exported Go functions, and they
would clash with the ones in `libretro.h`, so those are exported under another
name and wrapped in `shim.c`. The other twenty one are exported straight from
`core.go`.
`libretro.h` is vendored from
[libretro-common](https://github.com/libretro/libretro-common).
+75
View File
@@ -0,0 +1,75 @@
package main
/*
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
a2audio "github.com/ivanizag/izapple2/audio"
)
const sampleRate = a2audio.SampleRate
/*
The mixer needs no special handling here. It keeps its render clock a fixed
30 ms behind the newest sound event, and the core pushes one frame of events
and reads one frame of samples on every call, so the two stay in step and that
lag is just constant latency.
*/
type audioOutput struct {
mixer *a2audio.Mixer
samplesPerFrame float64
// Samples owed to the frontend, as the samples per frame are not a whole
// number
pending float64
mono []float32
stereo []C.int16_t
}
func newAudioOutput(clockMhz float64) *audioOutput {
samplesPerFrame := sampleRate * cyclesPerFrame / (clockMhz * 1_000_000)
// Room for a frame and a bit, the pending samples never add up to a full
// extra sample
size := int(samplesPerFrame) + 2
return &audioOutput{
mixer: a2audio.NewMixer(clockMhz),
samplesPerFrame: samplesPerFrame,
mono: make([]float32, size),
stereo: make([]C.int16_t, 2*size),
}
}
// render sends the samples of the frame to the frontend as interleaved stereo
func (s *audioOutput) render() {
s.pending += s.samplesPerFrame
samples := int(s.pending)
s.pending -= float64(samples)
if samples == 0 {
return
}
s.mixer.ReadSamples(s.mono[:samples])
for i, v := range s.mono[:samples] {
sample := toInt16(v)
s.stereo[2*i] = sample
s.stereo[2*i+1] = sample
}
C.shim_audio_sample_batch(&s.stereo[0], C.size_t(samples))
}
func toInt16(v float32) C.int16_t {
scaled := int32(v * 32767)
if scaled > 32767 {
scaled = 32767
} else if scaled < -32768 {
scaled = -32768
}
return C.int16_t(scaled)
}
+307
View File
@@ -0,0 +1,307 @@
/*
A libretro core for izapple2.
Unlike the other frontends, that let the emulation run free on its own
goroutine, the libretro frontend owns the frame timing: it calls retro_run()
once per frame and the core has to advance the machine by exactly one frame,
hand over one video frame and the matching audio samples, and return. That is
what Apple2.RunCycles is for, and everything here happens on the thread that
calls retro_run().
Kept deliberately small: no super hi-res, no Videx video, no RGB card and no
savestates. See README.md.
*/
package main
/*
#include <stdlib.h>
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"unsafe"
"github.com/ivanizag/izapple2"
)
const (
// cyclesPerFrame is a full NTSC video frame, 65 cycles for each of the
// 262 lines
cyclesPerFrame = 17030
/*
fastModeFactor is how many frames worth of cycles are run in a single
frame while a device asks for fast mode, which the disk drive does
while the motor is on. Without it a DOS 3.3 boot, that takes some 37
million cycles of mostly waiting for the drive, would need more than
half a minute. The other frontends let the emulation run free instead,
but a libretro core has to return in time for the next frame, so the
speed up is capped. Sound is choppy while it lasts, as it is on the
other frontends.
*/
fastModeFactor = 16
)
// core is the state of the loaded core. All of it is touched only from the
// thread that calls retro_run().
type core struct {
a *izapple2.Apple2
video *videoOutput
audio *audioOutput
keyboard *keyboard
joysticks *joysticks
disks *diskControl
// Media to reload when the machine is rebuilt on reset
filenames []string
}
var theCore = core{disks: newDiskControl()}
// build creates the machine from the current core options and the media
func (c *core) build() bool {
c.close()
options := readOptions()
a, err := izapple2.CreateAppleFromModel(
options.model, buildOverrides(options.model), c.filenames)
if err != nil {
errorf("could not create the machine: %v", err)
return false
}
c.a = a
c.video = newVideoOutput(options.screenMode)
c.audio = newAudioOutput(a.GetClockMhz())
c.keyboard = newKeyboard(a)
c.joysticks = newJoysticks()
for _, source := range a.GetAudioSources() {
source.SetAudioSink(c.audio.mixer.NewSource())
}
a.SetJoysticksProvider(c.joysticks)
a.SetMouseProvider(newMouse())
a.Init()
return true
}
func (c *core) close() {
c.a = nil
c.video = nil
c.audio = nil
c.keyboard = nil
c.joysticks = nil
}
//export retro_api_version
func retro_api_version() C.unsigned {
return C.RETRO_API_VERSION
}
//export retro_get_system_info
func retro_get_system_info(info *C.struct_retro_system_info) {
info.library_name = cLibraryName
info.library_version = cLibraryVersion
info.valid_extensions = cValidExtensions
info.need_fullpath = true
info.block_extract = false
}
//export retro_get_system_av_info
func retro_get_system_av_info(info *C.struct_retro_system_av_info) {
clockMhz := izapple2.CPUClockMhz
if theCore.a != nil {
clockMhz = theCore.a.GetClockMhz()
}
info.geometry.base_width = frameWidth
info.geometry.base_height = frameHeight
info.geometry.max_width = frameWidth
info.geometry.max_height = frameHeight
// The image has two pixels per Apple II pixel, the display is 4:3
info.geometry.aspect_ratio = 4.0 / 3.0
info.timing.fps = C.double(clockMhz * 1_000_000 / cyclesPerFrame)
info.timing.sample_rate = C.double(sampleRate)
}
//export retro_set_environment
func retro_set_environment(cb C.retro_environment_t) {
C.shim_set_environment(cb)
// Take the log of the frontend before anything can need it
setupLog()
// The core boots to BASIC with no media
noGame := C.bool(true)
C.shim_environment(C.RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, unsafe.Pointer(&noGame))
setVariables()
}
//export retro_set_video_refresh
func retro_set_video_refresh(cb C.retro_video_refresh_t) {
C.shim_set_video_refresh(cb)
}
//export retro_set_audio_sample
func retro_set_audio_sample(cb C.retro_audio_sample_t) {
// Not used, the core delivers the samples in batches
}
//export retro_set_audio_sample_batch
func retro_set_audio_sample_batch(cb C.retro_audio_sample_batch_t) {
C.shim_set_audio_sample_batch(cb)
}
//export retro_set_input_poll
func retro_set_input_poll(cb C.retro_input_poll_t) {
C.shim_set_input_poll(cb)
}
//export retro_set_input_state
func retro_set_input_state(cb C.retro_input_state_t) {
C.shim_set_input_state(cb)
}
//export retro_init
func retro_init() {
}
//export retro_deinit
func retro_deinit() {
theCore.close()
}
//export retro_set_controller_port_device
func retro_set_controller_port_device(port C.unsigned, device C.unsigned) {
}
/*
The four entry points that take a const pointer are exported to C under
another name and reached through a wrapper in shim.c: cgo can not put const in
the declarations it generates, and they would clash with libretro.h.
*/
//export izapple2LoadGame
func izapple2LoadGame(info *C.struct_retro_game_info) C.bool {
format := C.enum_retro_pixel_format(C.RETRO_PIXEL_FORMAT_XRGB8888)
if !C.shim_environment(C.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, unsafe.Pointer(&format)) {
errorf("the frontend does not support the XRGB8888 pixel format")
return false
}
theCore.filenames = nil
theCore.disks = newDiskControl()
if info != nil && info.path != nil {
images, err := buildImageList(C.GoString(info.path))
if err != nil {
errorf("could not read the playlist: %v", err)
return false
}
theCore.disks.images = images
// Start with the diskette that was in use the last time
if initialIndex < uint(len(images)) {
theCore.disks.index = initialIndex
}
if path, ok := theCore.disks.current(); ok {
theCore.filenames = []string{path}
}
}
if !theCore.build() {
return false
}
registerKeyboardCallback()
registerDiskControl()
setInputDescriptors()
return true
}
//export izapple2LoadGameSpecial
func izapple2LoadGameSpecial(gameType C.unsigned, info *C.struct_retro_game_info, numInfo C.size_t) C.bool {
return false
}
//export retro_unload_game
func retro_unload_game() {
theCore.close()
theCore.filenames = nil
theCore.disks = newDiskControl()
}
//export retro_reset
func retro_reset() {
// Rebuild the machine instead of sending a reset to the 6502, so that a
// change of the model option is picked up with a cold boot
theCore.build()
}
//export retro_run
func retro_run() {
if theCore.a == nil {
return
}
C.shim_input_poll()
theCore.video.setScreenMode(readOptions().screenMode)
cycles := uint64(cyclesPerFrame)
if theCore.a.IsFastModeRequested() {
cycles *= fastModeFactor
}
theCore.a.RunCycles(cycles)
theCore.video.render(theCore.a.GetVideoSource())
theCore.audio.render()
}
//export retro_get_region
func retro_get_region() C.unsigned {
return C.RETRO_REGION_NTSC
}
//export retro_get_memory_data
func retro_get_memory_data(id C.unsigned) unsafe.Pointer {
return nil
}
//export retro_get_memory_size
func retro_get_memory_size(id C.unsigned) C.size_t {
return 0
}
// The core does not implement savestates, so it reports a size of zero and
// the frontend disables rewind, run ahead and netplay
//export retro_serialize_size
func retro_serialize_size() C.size_t {
return 0
}
//export retro_serialize
func retro_serialize(data unsafe.Pointer, size C.size_t) C.bool {
return false
}
//export izapple2Unserialize
func izapple2Unserialize(data unsafe.Pointer, size C.size_t) C.bool {
return false
}
//export retro_cheat_reset
func retro_cheat_reset() {
}
//export izapple2CheatSet
func izapple2CheatSet(index C.unsigned, enabled C.bool, code *C.char) {
}
func main() {}
+234
View File
@@ -0,0 +1,234 @@
package main
/*
#include <stdlib.h>
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"os"
"path/filepath"
"strings"
"unsafe"
)
/*
The disk control interface, what lets the frontend swap diskettes from its own
menu so that the games spanning several disks can be finished.
The list of diskettes comes from an .m3u playlist, the libretro convention for
multi disk content: a text file with one image per line. Loading a single disk
image gives a list of one.
The Apple II has no way to know that a diskette was taken out, and the drive
has no eject, so ejecting only bookkeeps. The diskette of the selected index is
inserted when the frontend closes the tray again, which is the order it always
uses: eject, select, insert.
*/
const m3uExtension = ".m3u"
type diskImage struct {
path string
label string
}
type diskControl struct {
images []diskImage
index uint
ejected bool
}
// initialIndex is what the frontend asks for before the content is loaded, to
// restore the diskette that was in use the last time
var initialIndex uint
func newDiskControl() *diskControl {
return &diskControl{}
}
// registerDiskControl offers the interface to the frontend, falling back to the
// older one that has no labels if the extended one is not known
func registerDiskControl() {
if !C.shim_set_disk_control_interface() {
logf("the frontend does not support swapping diskettes")
}
}
// buildImageList returns the diskettes of the content, expanding a playlist
func buildImageList(path string) ([]diskImage, error) {
if !strings.EqualFold(filepath.Ext(path), m3uExtension) {
return []diskImage{newDiskImage(path)}, nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
dir := filepath.Dir(path)
images := make([]diskImage, 0)
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !filepath.IsAbs(line) {
line = filepath.Join(dir, line)
}
images = append(images, newDiskImage(line))
}
return images, nil
}
func newDiskImage(path string) diskImage {
name := filepath.Base(path)
return diskImage{
path: path,
label: strings.TrimSuffix(name, filepath.Ext(name)),
}
}
// current returns the path of the selected diskette
func (d *diskControl) current() (string, bool) {
if d.index >= uint(len(d.images)) {
return "", false
}
return d.images[d.index].path, true
}
// insert puts the selected diskette in the first drive
func (d *diskControl) insert() bool {
path, ok := d.current()
if !ok {
return false
}
if theCore.a == nil {
return false
}
theCore.a.SendLoadDisk(0, path)
// A reset rebuilds the machine, keep it booting what is in the drive
theCore.filenames = []string{path}
return true
}
//export izapple2DiskSetEjectState
func izapple2DiskSetEjectState(ejected C.int) C.int {
d := theCore.disks
wasEjected := d.ejected
d.ejected = ejected != 0
if wasEjected && !d.ejected {
// The tray is closed again, load whatever was selected meanwhile.
// With no diskette selected there is nothing to load, and that is not
// a failure: the frontend is just taking the diskette out.
d.insert()
}
return 1
}
//export izapple2DiskGetEjectState
func izapple2DiskGetEjectState() C.int {
return boolToC(theCore.disks.ejected)
}
//export izapple2DiskGetImageIndex
func izapple2DiskGetImageIndex() C.uint {
return C.uint(theCore.disks.index)
}
//export izapple2DiskSetImageIndex
func izapple2DiskSetImageIndex(index C.uint) C.int {
d := theCore.disks
// Any index from the number of diskettes up means taking the diskette out
// without putting another one in, so every value is accepted and it is
// current() that tells whether there is something to insert
d.index = uint(index)
// The frontends eject before selecting, but swap right away for the ones
// that do not, instead of leaving the drive with the previous diskette
if !d.ejected {
d.insert()
}
return 1
}
//export izapple2DiskGetNumImages
func izapple2DiskGetNumImages() C.uint {
return C.uint(len(theCore.disks.images))
}
//export izapple2DiskReplaceImageIndex
func izapple2DiskReplaceImageIndex(index C.uint, info *C.struct_retro_game_info) C.int {
d := theCore.disks
if uint(index) >= uint(len(d.images)) {
return 0
}
if info == nil || info.path == nil {
// Remove the entry
d.images = append(d.images[:index], d.images[index+1:]...)
if d.index >= uint(len(d.images)) && len(d.images) > 0 {
d.index = uint(len(d.images)) - 1
}
return 1
}
d.images[index] = newDiskImage(C.GoString(info.path))
return 1
}
//export izapple2DiskAddImageIndex
func izapple2DiskAddImageIndex() C.int {
theCore.disks.images = append(theCore.disks.images, diskImage{})
return 1
}
//export izapple2DiskSetInitialImage
func izapple2DiskSetInitialImage(index C.uint, path *C.char) C.int {
initialIndex = uint(index)
return 1
}
//export izapple2DiskGetImagePath
func izapple2DiskGetImagePath(index C.uint, buffer *C.char, length C.size_t) C.int {
d := theCore.disks
if uint(index) >= uint(len(d.images)) {
return 0
}
return copyToBuffer(buffer, length, d.images[index].path)
}
//export izapple2DiskGetImageLabel
func izapple2DiskGetImageLabel(index C.uint, buffer *C.char, length C.size_t) C.int {
d := theCore.disks
if uint(index) >= uint(len(d.images)) {
return 0
}
return copyToBuffer(buffer, length, d.images[index].label)
}
// copyToBuffer writes a null terminated string in a buffer owned by the
// frontend, truncating it if it does not fit
func copyToBuffer(buffer *C.char, length C.size_t, value string) C.int {
if buffer == nil || length == 0 {
return 0
}
destination := unsafe.Slice((*byte)(unsafe.Pointer(buffer)), int(length))
written := copy(destination[:len(destination)-1], value)
destination[written] = 0
return 1
}
func boolToC(value bool) C.int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,35 @@
# Software Information
display_name = "Apple II (izapple2)"
authors = "Ivan Izaguirre"
supported_extensions = "dsk|do|po|nib|woz|2mg|hdv|wav|zip|gz|m3u"
corename = "izapple2"
categories = "Emulator"
license = "GPLv3"
permissions = ""
display_version = "2.0"
# Hardware Information
manufacturer = "Apple"
systemname = "Apple II"
systemid = "apple2"
# Libretro Features
supports_no_game = "true"
savestate = "false"
savestate_features = "null"
cheats = "false"
input_descriptors = "true"
memory_descriptors = "false"
libretro_saves = "true"
core_options = "true"
core_options_version = "0"
load_subsystem = "false"
hw_render = "false"
needs_fullpath = "true"
disk_control = "true"
is_experimental = "false"
firmware_count = 0
description = "An emulator of the Apple II+ and the Apple //e, written in Go. The ROMs and a DOS 3.3 diskette are built into the core, so it starts into Applesoft BASIC with no content loaded. It boots 5 1/4 diskettes in DSK, PO, NIB and WOZ format, 3.5 disks and hard disks in 2MG and HDV format, and reads cassette tapes from WAV recordings, compressed with gzip or zip or not. Games spanning several diskettes are loaded from an M3U playlist and swapped from the disk control menu. The default machine is an enhanced Apple //e with a Mockingboard, set up for playing games."
notes = "The core has no savestates, so rewind, run ahead and netplay are not available.|Super hi-res, the Videx 80 column video and the RGB card modes are not rendered, so the box art of collections like Total Replay is not shown.|For a game on several diskettes, load an M3U playlist listing them, one path per line.|Games save into an overlay file in the save directory, holding only the parts written. The disk image loaded is never modified, diskettes and hard disks alike, so compressed and read only disks can be saved to as well.|WOZ and NIB diskettes cannot save.|The gaps between the scan lines are not drawn, use a shader for the CRT look. The monitor, NTSC colour or green phosphor, is a core option.|Disk access runs faster than the real machine, and the sound breaks up while it does."
+72
View File
@@ -0,0 +1,72 @@
package main
/*
#include "libretro.h"
#include "shim.h"
*/
import "C"
/*
joysticks maps the first libretro controller port to the Apple II paddles.
The methods are called from the emulation, which under RunCycles runs on the
same thread as retro_run, so the input state can be read straight from the
frontend as the machine asks for it.
*/
type joysticks struct {
// The open and closed apple keys, that the Apple II reads as the buttons
// of the first joystick
openApple bool
closedApple bool
}
func newJoysticks() *joysticks {
return &joysticks{}
}
func (j *joysticks) ReadButton(i int) bool {
switch i {
case 0:
return j.openApple || joypadPressed(C.RETRO_DEVICE_ID_JOYPAD_A)
case 1:
return j.closedApple || joypadPressed(C.RETRO_DEVICE_ID_JOYPAD_B)
case 2:
return joypadPressed(C.RETRO_DEVICE_ID_JOYPAD_X)
}
return false
}
func (j *joysticks) ReadPaddle(i int) (uint8, bool) {
var negative, positive, axis C.uint
switch i {
case 0:
negative = C.RETRO_DEVICE_ID_JOYPAD_LEFT
positive = C.RETRO_DEVICE_ID_JOYPAD_RIGHT
axis = C.RETRO_DEVICE_ID_ANALOG_X
case 1:
negative = C.RETRO_DEVICE_ID_JOYPAD_UP
positive = C.RETRO_DEVICE_ID_JOYPAD_DOWN
axis = C.RETRO_DEVICE_ID_ANALOG_Y
default:
// Only one joystick, the second one reads centered
return 128, false
}
// The d-pad takes precedence, for the controllers with no analog stick
if joypadPressed(negative) {
return 0, true
}
if joypadPressed(positive) {
return 255, true
}
value := C.shim_input_state(0, C.RETRO_DEVICE_ANALOG,
C.RETRO_DEVICE_INDEX_ANALOG_LEFT, axis)
// The analog axis goes from -32768 to 32767, the paddle from 0 to 255
return uint8((int32(value) + 32768) >> 8), true
}
func joypadPressed(id C.uint) bool {
return C.shim_input_state(0, C.RETRO_DEVICE_JOYPAD, 0, id) != 0
}
+95
View File
@@ -0,0 +1,95 @@
package main
/*
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"unsafe"
"github.com/ivanizag/izapple2"
)
// keyboard turns the key events of the frontend into the codes the Apple II
// keyboard would produce
type keyboard struct {
channel *izapple2.KeyboardChannel
}
func newKeyboard(a *izapple2.Apple2) *keyboard {
return &keyboard{
channel: izapple2.NewKeyboardChannel(a),
}
}
// registerKeyboardCallback asks the frontend to report the key events. The
// callback is a C function that forwards to izapple2KeyboardEvent.
func registerKeyboardCallback() {
callback := C.struct_retro_keyboard_callback{
callback: C.retro_keyboard_event_t(C.shim_keyboard_callback),
}
C.shim_environment(C.RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK, unsafe.Pointer(&callback))
}
//export izapple2KeyboardEvent
func izapple2KeyboardEvent(down C.int, keycode C.uint, character C.uint32_t, keyModifiers C.uint16_t) {
if theCore.keyboard == nil {
return
}
// The Apple II reads the open and closed apple keys as joystick buttons,
// so they are a state and not a keystroke
switch keycode {
case C.RETROK_LALT:
theCore.joysticks.openApple = down != 0
return
case C.RETROK_RALT:
theCore.joysticks.closedApple = down != 0
return
}
if down == 0 {
return
}
if key, ok := specialKey(uint32(keycode), uint16(keyModifiers)); ok {
theCore.keyboard.channel.PutChar(key)
return
}
// PutRune drops what is not printable ASCII and applies the upper case of
// the models without lower case
theCore.keyboard.channel.PutRune(rune(character))
}
// specialKey returns the code of the keys that do not come as a printable
// character, and of the control combinations
func specialKey(keycode uint32, modifiers uint16) (uint8, bool) {
switch keycode {
case C.RETROK_RETURN, C.RETROK_KP_ENTER:
return 13, true
case C.RETROK_TAB:
return 9, true
case C.RETROK_ESCAPE:
return 27, true
case C.RETROK_BACKSPACE, C.RETROK_LEFT:
return 8, true
case C.RETROK_RIGHT:
return 21, true
case C.RETROK_UP:
return 11, true
case C.RETROK_DOWN:
return 10, true
case C.RETROK_DELETE:
return 127, true
}
if modifiers&C.RETROKMOD_CTRL != 0 &&
keycode >= C.RETROK_a && keycode <= C.RETROK_z {
return uint8(keycode-C.RETROK_a) + 1, true
}
return 0, false
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
package main
/*
mouse reports a mouse that never moves and is never pressed.
The core does not map the host pointer to the Apple II mouse, but the mouse
card reads its provider without checking that there is one, so the models that
carry the card, like "desktop", need one to be attached.
*/
type mouse struct{}
func newMouse() *mouse {
return &mouse{}
}
func (m *mouse) ReadMouse() (x uint16, y uint16, pressed bool) {
return 0, 0, false
}
+139
View File
@@ -0,0 +1,139 @@
package main
/*
#include <stdlib.h>
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"slices"
"strings"
"unsafe"
"github.com/ivanizag/izapple2"
"github.com/ivanizag/izapple2/screen"
)
const (
// defaultModel is the machine set up for playing games, see
// configs/gaming.cfg
defaultModel = "gaming"
// unsupportedModel is left out of the core options, its reason to exist is
// the Videx Ultraterm video that this core does not render
unsupportedModel = "ultraterm"
)
// The strings handed to the frontend have to outlive the call, so they are
// allocated once and never freed
var (
cLibraryName = C.CString("izapple2")
cLibraryVersion = C.CString("2.0")
cValidExtensions = C.CString("dsk|do|po|nib|woz|2mg|hdv|wav|zip|gz|m3u")
cKeyModel = C.CString("izapple2_model")
cKeyScreen = C.CString("izapple2_screen")
)
type options struct {
model string
screenMode int
}
// readOptions returns the settings currently chosen in the frontend
func readOptions() options {
return options{
model: readVariable(cKeyModel, defaultModel),
screenMode: screenModeByName(readVariable(cKeyScreen, "color")),
}
}
// screenModeByName is the monitor to emulate. Both are the modes without scan
// lines, the frontend draws those with a shader if it wants them.
func screenModeByName(name string) int {
if name == "green" {
return screen.ScreenModeGreen
}
return screen.ScreenModeColor
}
/*
videoOverrides takes out the cards that generate the video this core does not
render. The VidHD card, that adds super hi-res, is always in slot 2. The Videx
80 column card is in slot 3 of the two models that carry it.
*/
func videoOverrides(model string) map[string]string {
overrides := map[string]string{"s2": "empty"}
switch model {
case "2plus", "basis108":
overrides["s3"] = "empty"
}
return overrides
}
// buildOverrides is everything the core imposes on the model chosen
func buildOverrides(model string) map[string]string {
overrides := videoOverrides(model)
for key, value := range saveOverrides() {
overrides[key] = value
}
return overrides
}
func readVariable(key *C.char, fallback string) string {
variable := C.struct_retro_variable{key: key}
ok := C.shim_environment(C.RETRO_ENVIRONMENT_GET_VARIABLE, unsafe.Pointer(&variable))
if !ok || variable.value == nil {
return fallback
}
value := C.GoString(variable.value)
if value == "" {
return fallback
}
return value
}
// setVariables declares the core options. The frontend keeps the array, so it
// is built in C memory.
func setVariables() {
variables := []struct{ key, value string }{
{"izapple2_model", "Machine model; " + modelChoices()},
{"izapple2_screen", "Monitor; color|green"},
}
// One extra entry, the array is terminated by a pair of null pointers
count := len(variables) + 1
size := C.size_t(count) * C.size_t(unsafe.Sizeof(C.struct_retro_variable{}))
array := (*C.struct_retro_variable)(C.malloc(size))
entries := unsafe.Slice(array, count)
for i, variable := range variables {
entries[i].key = C.CString(variable.key)
entries[i].value = C.CString(variable.value)
}
entries[count-1].key = nil
entries[count-1].value = nil
C.shim_environment(C.RETRO_ENVIRONMENT_SET_VARIABLES, unsafe.Pointer(array))
}
// modelChoices lists the models for the option, the default one first
func modelChoices() string {
models, err := izapple2.AvailableModels()
if err != nil {
logf("could not list the models: %v", err)
return defaultModel
}
choices := []string{defaultModel}
for _, model := range models {
if model != defaultModel && model != unsupportedModel {
choices = append(choices, model)
}
}
slices.Sort(choices[1:])
return strings.Join(choices, "|")
}
+103
View File
@@ -0,0 +1,103 @@
package main
/*
#include <stdlib.h>
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"fmt"
"os"
"unsafe"
)
// messageFrames is how long a message stays on screen, about three seconds
const messageFrames = 180
// setupLog takes the log of the frontend, if it offers one. Everything goes to
// stderr until it does, and if it never does.
func setupLog() {
var callback C.struct_retro_log_callback
if C.shim_environment(C.RETRO_ENVIRONMENT_GET_LOG_INTERFACE, unsafe.Pointer(&callback)) {
C.shim_set_log(callback.log)
}
}
// logf reports something worth knowing to whoever is watching the log
func logf(format string, args ...any) {
writeLog(C.RETRO_LOG_INFO, fmt.Sprintf(format, args...))
}
// errorf reports a failure the user has to know about: it goes to the log and
// on screen, as nobody reads the log of a frontend
func errorf(format string, args ...any) {
message := fmt.Sprintf(format, args...)
writeLog(C.RETRO_LOG_ERROR, message)
showMessage(message)
}
func writeLog(level C.int, message string) {
if !C.shim_has_log() {
fmt.Fprintf(os.Stderr, "[izapple2] %v\n", message)
return
}
cMessage := C.CString(message)
defer C.free(unsafe.Pointer(cMessage))
C.shim_log(level, cMessage)
}
// showMessage puts a notice on the screen of the frontend
func showMessage(message string) {
cMessage := C.CString(message)
defer C.free(unsafe.Pointer(cMessage))
notice := C.struct_retro_message{
msg: cMessage,
frames: messageFrames,
}
C.shim_environment(C.RETRO_ENVIRONMENT_SET_MESSAGE, unsafe.Pointer(&notice))
}
// setInputDescriptors names the controls in the menus of the frontend, so that
// remapping them shows what they do on the Apple II
func setInputDescriptors() {
type descriptor struct {
device C.uint
index C.uint
id C.uint
description string
}
descriptors := []descriptor{
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_LEFT, "Paddle 0 left"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_RIGHT, "Paddle 0 right"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_UP, "Paddle 1 up"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_DOWN, "Paddle 1 down"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_A, "Button 0 (open apple)"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_B, "Button 1 (closed apple)"},
{C.RETRO_DEVICE_JOYPAD, 0, C.RETRO_DEVICE_ID_JOYPAD_X, "Button 2"},
{C.RETRO_DEVICE_ANALOG, C.RETRO_DEVICE_INDEX_ANALOG_LEFT, C.RETRO_DEVICE_ID_ANALOG_X, "Paddle 0"},
{C.RETRO_DEVICE_ANALOG, C.RETRO_DEVICE_INDEX_ANALOG_LEFT, C.RETRO_DEVICE_ID_ANALOG_Y, "Paddle 1"},
}
// The frontend keeps the array until the content is unloaded, so it is
// built in C memory and ends with a null description
count := len(descriptors) + 1
size := C.size_t(count) * C.size_t(unsafe.Sizeof(C.struct_retro_input_descriptor{}))
array := (*C.struct_retro_input_descriptor)(C.malloc(size))
entries := unsafe.Slice(array, count)
for i, d := range descriptors {
entries[i].port = 0
entries[i].device = d.device
entries[i].index = d.index
entries[i].id = d.id
entries[i].description = C.CString(d.description)
}
entries[count-1].description = nil
C.shim_environment(C.RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS, unsafe.Pointer(array))
}
+61
View File
@@ -0,0 +1,61 @@
package main
/*
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"unsafe"
)
/*
Where the games save.
The emulation writes to a diskette as the software saves, and by default it
writes straight into the file the diskette was loaded from. That is what the
command line does, but not what somebody loading a game in a libretro frontend
expects: the content should be left alone and the saves should go to the save
directory, where the frontend backs them up.
So the core points the emulator at the save directory and the changes go to an
overlay file next to the other saves, one per diskette. Only the tracks the
software writes are stored, so an overlay is a few kilobytes rather than a copy
of the diskette, and the image loaded is never touched.
It also means that the diskettes that could not be written at all, the ones
inside a zip or a gzip, the ones loaded from an URL and the DOS 3.3 built into
the core, can now be saved to.
*/
/*
saveOverrides is the configuration that sends the changes written to the disks
to the save directory. It is a setting of the machine, applied when it is built,
so it is given as a configuration override and not set afterwards: the disks are
inserted while the machine is being built.
*/
func saveOverrides() map[string]string {
directory, ok := saveDirectory()
if !ok {
logf("the frontend gave no save directory, the disks will be written where they are")
return nil
}
return map[string]string{"saveDir": directory}
}
// saveDirectory returns the directory the frontend keeps the saves in
func saveDirectory() (string, bool) {
var directory *C.char
ok := C.shim_environment(C.RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY, unsafe.Pointer(&directory))
if !ok || directory == nil {
return "", false
}
path := C.GoString(directory)
if path == "" {
return "", false
}
return path, true
}
+178
View File
@@ -0,0 +1,178 @@
#include "shim.h"
#include "_cgo_export.h"
static retro_log_printf_t log_cb;
void shim_set_log(retro_log_printf_t cb) { log_cb = cb; }
bool shim_has_log(void) { return log_cb != NULL; }
void shim_log(int level, const char *message)
{
if (log_cb)
log_cb((enum retro_log_level)level, "%s\n", message);
}
static retro_environment_t env_cb;
static retro_video_refresh_t video_cb;
static retro_audio_sample_batch_t audio_batch_cb;
static retro_input_poll_t input_poll_cb;
static retro_input_state_t input_state_cb;
void shim_set_environment(retro_environment_t cb) { env_cb = cb; }
void shim_set_video_refresh(retro_video_refresh_t cb) { video_cb = cb; }
void shim_set_audio_sample_batch(retro_audio_sample_batch_t cb) { audio_batch_cb = cb; }
void shim_set_input_poll(retro_input_poll_t cb) { input_poll_cb = cb; }
void shim_set_input_state(retro_input_state_t cb) { input_state_cb = cb; }
bool shim_environment(unsigned cmd, void *data)
{
if (!env_cb)
return false;
return env_cb(cmd, data);
}
void shim_video_refresh(const void *data, unsigned width, unsigned height, size_t pitch)
{
if (video_cb)
video_cb(data, width, height, pitch);
}
size_t shim_audio_sample_batch(const int16_t *data, size_t frames)
{
if (!audio_batch_cb)
return 0;
return audio_batch_cb(data, frames);
}
void shim_input_poll(void)
{
if (input_poll_cb)
input_poll_cb();
}
int16_t shim_input_state(unsigned port, unsigned device, unsigned index, unsigned id)
{
if (!input_state_cb)
return 0;
return input_state_cb(port, device, index, id);
}
void shim_keyboard_callback(bool down, unsigned keycode, uint32_t character, uint16_t key_modifiers)
{
/* izapple2KeyboardEvent is exported by keyboard.go */
izapple2KeyboardEvent(down ? 1 : 0, keycode, character, key_modifiers);
}
/*
The disk control interface. The frontend needs C function pointers, so these
forward to the Go functions exported by disk.go.
*/
static bool disk_set_eject_state(bool ejected)
{
return izapple2DiskSetEjectState(ejected ? 1 : 0) != 0;
}
static bool disk_get_eject_state(void)
{
return izapple2DiskGetEjectState() != 0;
}
static unsigned disk_get_image_index(void)
{
return izapple2DiskGetImageIndex();
}
static bool disk_set_image_index(unsigned index)
{
return izapple2DiskSetImageIndex(index) != 0;
}
static unsigned disk_get_num_images(void)
{
return izapple2DiskGetNumImages();
}
static bool disk_replace_image_index(unsigned index, const struct retro_game_info *info)
{
return izapple2DiskReplaceImageIndex(index, (struct retro_game_info *)info) != 0;
}
static bool disk_add_image_index(void)
{
return izapple2DiskAddImageIndex() != 0;
}
static bool disk_set_initial_image(unsigned index, const char *path)
{
return izapple2DiskSetInitialImage(index, (char *)path) != 0;
}
static bool disk_get_image_path(unsigned index, char *s, size_t len)
{
return izapple2DiskGetImagePath(index, s, len) != 0;
}
static bool disk_get_image_label(unsigned index, char *s, size_t len)
{
return izapple2DiskGetImageLabel(index, s, len) != 0;
}
bool shim_set_disk_control_interface(void)
{
static const struct retro_disk_control_ext_callback ext = {
disk_set_eject_state,
disk_get_eject_state,
disk_get_image_index,
disk_set_image_index,
disk_get_num_images,
disk_replace_image_index,
disk_add_image_index,
disk_set_initial_image,
disk_get_image_path,
disk_get_image_label
};
if (shim_environment(RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE, (void *)&ext))
return true;
/* An older frontend, that knows only the first seven and has no labels */
static const struct retro_disk_control_callback basic = {
disk_set_eject_state,
disk_get_eject_state,
disk_get_image_index,
disk_set_image_index,
disk_get_num_images,
disk_replace_image_index,
disk_add_image_index
};
return shim_environment(RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE, (void *)&basic);
}
/*
The libretro entry points that take a const pointer are defined here. cgo can
not put const in the declarations it generates for the exported Go functions,
so those four would clash with the ones in libretro.h. The rest of the
retro_ functions are exported straight from core.go.
*/
bool retro_load_game(const struct retro_game_info *game)
{
return izapple2LoadGame((struct retro_game_info *)game);
}
bool retro_load_game_special(unsigned game_type, const struct retro_game_info *info, size_t num_info)
{
return izapple2LoadGameSpecial(game_type, (struct retro_game_info *)info, num_info);
}
bool retro_unserialize(const void *data, size_t size)
{
return izapple2Unserialize((void *)data, size);
}
void retro_cheat_set(unsigned index, bool enabled, const char *code)
{
izapple2CheatSet(index, enabled, (char *)code);
}
+43
View File
@@ -0,0 +1,43 @@
/*
Bridge between the libretro C API and Go.
Go can not call the function pointers that the libretro frontend hands to the
core, so every callback is stored here and reached through a plain C function.
The keyboard callback goes the other way: the frontend needs a C function
pointer, so shim_keyboard_callback forwards to the Go side.
*/
#ifndef IZAPPLE2_SHIM_H
#define IZAPPLE2_SHIM_H
#include "libretro.h"
/* Store the callbacks given by the frontend */
void shim_set_environment(retro_environment_t cb);
void shim_set_video_refresh(retro_video_refresh_t cb);
void shim_set_audio_sample_batch(retro_audio_sample_batch_t cb);
void shim_set_input_poll(retro_input_poll_t cb);
void shim_set_input_state(retro_input_state_t cb);
/* Call the callbacks given by the frontend */
bool shim_environment(unsigned cmd, void *data);
void shim_video_refresh(const void *data, unsigned width, unsigned height, size_t pitch);
size_t shim_audio_sample_batch(const int16_t *data, size_t frames);
void shim_input_poll(void);
int16_t shim_input_state(unsigned port, unsigned device, unsigned index, unsigned id);
/* The keyboard callback the core registers with the frontend */
void shim_keyboard_callback(bool down, unsigned keycode, uint32_t character, uint16_t key_modifiers);
/* Offer the disk control interface, extended if the frontend knows it */
bool shim_set_disk_control_interface(void);
/*
The log of the frontend. It is a variadic function, which Go can not call, so
the message is formatted on the Go side and passed as a single string.
*/
void shim_set_log(retro_log_printf_t cb);
bool shim_has_log(void);
void shim_log(int level, const char *message);
#endif
+89
View File
@@ -0,0 +1,89 @@
package main
/*
#include "libretro.h"
#include "shim.h"
*/
import "C"
import (
"unsafe"
"github.com/ivanizag/izapple2/screen"
)
/*
The core renders every mode into a framebuffer of a fixed size, so the geometry
never has to change.
Only the screen modes without scan lines are used, so the image keeps its 192
lines instead of becoming four times taller. Drawing the gaps is left to the
shaders of the frontend, which do it better. The colour of the phosphor is
another matter and is a core option: no shader turns the colour picture into the
half width pixels of a green monitor.
With super hi-res, the Videx video and the RGB card left out, every remaining
mode is generated as 560x192, and the NTSC color filter widens it by four
pixels. Green skips that filter and stays at 560, so the columns it does not
cover are left black.
*/
const (
frameWidth = 564
frameHeight = 192
)
type videoOutput struct {
buffer []uint32
screenMode int
}
func newVideoOutput(screenMode int) *videoOutput {
return &videoOutput{
buffer: make([]uint32, frameWidth*frameHeight),
screenMode: screenMode,
}
}
func (v *videoOutput) setScreenMode(screenMode int) {
v.screenMode = screenMode
}
// render builds the frame and hands it to the frontend as XRGB8888
func (v *videoOutput) render(vs screen.VideoSource) {
img := screen.Snapshot(vs, v.screenMode)
if img == nil {
return
}
// A mode wider or taller than the framebuffer should not happen with the
// cards this core allows, but crop it instead of writing out of bounds
bounds := img.Bounds()
width := min(bounds.Dx(), frameWidth)
height := min(bounds.Dy(), frameHeight)
for y := range height {
src := y * img.Stride
dst := y * frameWidth
for x := range width {
p := src + 4*x
v.buffer[dst+x] = uint32(img.Pix[p])<<16 |
uint32(img.Pix[p+1])<<8 |
uint32(img.Pix[p+2])
}
for x := width; x < frameWidth; x++ {
v.buffer[dst+x] = 0
}
}
for y := height; y < frameHeight; y++ {
dst := y * frameWidth
for x := range frameWidth {
v.buffer[dst+x] = 0
}
}
C.shim_video_refresh(
unsafe.Pointer(&v.buffer[0]),
C.uint(frameWidth),
C.uint(frameHeight),
C.size_t(frameWidth*4))
}
+45
View File
@@ -275,3 +275,48 @@ func CreateConfiguredApple() (*Apple2, error) {
}
return a, nil
}
// CreateAppleFromModel builds a machine without reading the command line, for
// the frontends that get their settings elsewhere, like the libretro core.
// The model is the name of a preconfigured model, as returned by
// AvailableModels, or the path to a .cfg file. The overrides replace the
// values of the model, keyed as the command line flags. The filenames are the
// media to load, assigned to the slots as the positional arguments of the
// command line. Video, keyboard and speaker won't be defined.
func CreateAppleFromModel(model string, overrides map[string]string, filenames []string) (*Apple2, error) {
models, _, err := loadConfigurationModelsAndDefault()
if err != nil {
return nil, err
}
overrideValues := newConfiguration()
for key, value := range overrides {
overrideValues.set(key, value)
}
configuration, err := models.getWithOverrides(model, overrideValues)
if err != nil {
return nil, err
}
err = expandSlotConfigurations(configuration)
if err != nil {
return nil, err
}
err = processPositionalFilenames(configuration, filenames)
if err != nil {
return nil, err
}
return configure(configuration)
}
// AvailableModels returns the names of the preconfigured models
func AvailableModels() ([]string, error) {
models, _, err := loadConfigurationModelsAndDefault()
if err != nil {
return nil, err
}
return models.availableModels(), nil
}