Skip to content

Commit 377742d

Browse files
committed
Created interface for Gemini agent.
1 parent b9a516e commit 377742d

File tree

3 files changed

+300
-0
lines changed

3 files changed

+300
-0
lines changed

GEMINI.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Gemini CLI Emulator Instrumentation
2+
3+
This project includes a programmatic interface (`Agent`) designed for automated debugging, testing, and AI-assisted troubleshooting of Game Boy ROMs.
4+
5+
## Agent API
6+
7+
The `eu.rekawek.coffeegb.controller.Agent` class provides the following capabilities:
8+
9+
### Execution Control
10+
- `tick()`: Run the emulator for one M-cycle (4 clock cycles).
11+
- `step()`: Run until the next instruction starts or the CPU enters a HALT/STOP state.
12+
- `runUntilFrame(maxTicks)`: Run until a new frame is rendered or `maxTicks` is reached.
13+
- `runTicks(n)`: Run for exactly `n` M-cycles.
14+
15+
### State Access
16+
- `getRegisters()`: Access CPU registers (PC, AF, BC, DE, HL, SP).
17+
- `getByte(address)` / `getMemory(address, length)`: Read from memory.
18+
- `writeMemory(address, value)`: Write to memory.
19+
- `getRomBank()`: Get the currently active switchable ROM bank.
20+
- `getCpuState()`: Get internal CPU state (OPCODE, RUNNING, HALTED, etc.).
21+
- `isImeEnabled()` / `getIF()` / `getIE()`: Access interrupt controller state.
22+
23+
### Media & Input
24+
- `getFrame()`: Retrieve the current screen as a `BufferedImage`.
25+
- `getAudio()`: Collect all audio samples generated since the last call.
26+
- `pressButton(button)` / `releaseButton(button)`: Simulate joypad input.
27+
28+
### Debugging
29+
- `disassemble(address)`: Get a human-readable Z80 disassembly of the instruction at the given address.
30+
31+
## Running Tests
32+
33+
You can create Kotlin scripts in `controller/src/test/java` to automate debugging sessions.
34+
35+
### Example: TestAgent
36+
A basic example that runs a ROM for 100 frames and saves a screenshot:
37+
```bash
38+
mvn test-compile -pl controller &&
39+
mvn exec:java -pl controller
40+
-Dexec.mainClass="eu.rekawek.coffeegb.controller.TestAgentKt"
41+
-Dexec.classpathScope=test
42+
```
43+
44+
### Manual Execution (Bypassing Maven Cache)
45+
If `mvn exec:java` provides stale results, use a direct `java` command:
46+
```bash
47+
# Generate classpath
48+
mvn dependency:build-classpath -pl controller | grep -A 1 "Dependencies classpath:" | grep -v "Dependencies classpath:" | grep -v "\[INFO\]" > cp.txt
49+
50+
# Run
51+
java -cp $(cat cp.txt):core/target/classes:controller/target/classes:controller/target/test-classes
52+
eu.rekawek.coffeegb.controller.YourTestClassNameKt
53+
```
54+
55+
## Case Study: Zerd no Densetsu (Legend of Zerd)
56+
This game was fixed by addressing the **STAT IRQ Bug**. The emulator was triggering an immediate STAT interrupt when the game enabled it via a write to `0xFF41` while the condition (`LY=LYC`) was already true. On real hardware, this rising edge is missed or suppressed.
57+
58+
The fix in `StatRegister.java` updates the internal triggered state during the write to prevent this spurious interrupt, allowing the game to safely switch ROM banks before the handler runs.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package eu.rekawek.coffeegb.controller
2+
3+
import eu.rekawek.coffeegb.core.Gameboy
4+
import eu.rekawek.coffeegb.core.cpu.Opcodes
5+
import eu.rekawek.coffeegb.core.cpu.Registers
6+
import eu.rekawek.coffeegb.core.events.EventBusImpl
7+
import eu.rekawek.coffeegb.core.gpu.Display
8+
import eu.rekawek.coffeegb.core.joypad.Button
9+
import eu.rekawek.coffeegb.core.joypad.ButtonPressEvent
10+
import eu.rekawek.coffeegb.core.joypad.ButtonReleaseEvent
11+
import eu.rekawek.coffeegb.core.memory.cart.Rom
12+
import eu.rekawek.coffeegb.core.sound.Sound
13+
import java.awt.image.BufferedImage
14+
import java.io.File
15+
import java.util.concurrent.LinkedBlockingQueue
16+
17+
class Agent(romFile: File) {
18+
private val rom = Rom(romFile)
19+
private val config = Gameboy.GameboyConfiguration(rom)
20+
private val eventBus = EventBusImpl(null, null, false)
21+
private val gameboy = config.build()
22+
23+
private val frameQueue = LinkedBlockingQueue<IntArray>()
24+
private val soundQueue = LinkedBlockingQueue<IntArray>()
25+
26+
init {
27+
gameboy.init(eventBus, eu.rekawek.coffeegb.core.serial.SerialEndpoint.NULL_ENDPOINT, null)
28+
eventBus.register({ event ->
29+
val pixels = IntArray(Display.DISPLAY_WIDTH * Display.DISPLAY_HEIGHT)
30+
event.toRgb(pixels, false)
31+
frameQueue.put(pixels)
32+
if (frameQueue.size > 10) frameQueue.poll()
33+
}, Display.DmgFrameReadyEvent::class.java)
34+
eventBus.register({ event ->
35+
val pixels = IntArray(Display.DISPLAY_WIDTH * Display.DISPLAY_HEIGHT)
36+
event.toRgb(pixels)
37+
frameQueue.put(pixels)
38+
if (frameQueue.size > 10) frameQueue.poll()
39+
}, Display.GbcFrameReadyEvent::class.java)
40+
eventBus.register({ event ->
41+
soundQueue.put(event.buffer.clone())
42+
if (soundQueue.size > 100) soundQueue.poll()
43+
}, Sound.SoundSampleEvent::class.java)
44+
}
45+
46+
fun tick() {
47+
gameboy.tick()
48+
}
49+
50+
fun step() {
51+
val cpu = gameboy.cpu
52+
// Tick at least once to move away from current state
53+
gameboy.tick()
54+
// Continue ticking until we reach the start of the next instruction or a halt state
55+
while (cpu.state != eu.rekawek.coffeegb.core.cpu.Cpu.State.OPCODE &&
56+
cpu.state != eu.rekawek.coffeegb.core.cpu.Cpu.State.HALTED &&
57+
cpu.state != eu.rekawek.coffeegb.core.cpu.Cpu.State.STOPPED
58+
) {
59+
gameboy.tick()
60+
}
61+
}
62+
63+
fun runUntilFrame(maxTicks: Int = Gameboy.TICKS_PER_SEC) {
64+
var ticks = 0
65+
while (!gameboy.tick() && ticks < maxTicks) {
66+
ticks++
67+
}
68+
}
69+
70+
fun runTicks(ticks: Int) {
71+
repeat(ticks) {
72+
gameboy.tick()
73+
}
74+
}
75+
76+
fun isLcdEnabled(): Boolean {
77+
return gameboy.gpu.isLcdEnabled
78+
}
79+
80+
fun getLcdc(): Int {
81+
return gameboy.gpu.lcdc.get()
82+
}
83+
84+
fun getLY(): Int {
85+
return gameboy.gpu.registers.get(eu.rekawek.coffeegb.core.gpu.GpuRegister.LY)
86+
}
87+
88+
fun getFrame(): BufferedImage? {
89+
val pixels = frameQueue.poll() ?: return null
90+
val img = BufferedImage(Display.DISPLAY_WIDTH, Display.DISPLAY_HEIGHT, BufferedImage.TYPE_INT_RGB)
91+
img.setRGB(0, 0, Display.DISPLAY_WIDTH, Display.DISPLAY_HEIGHT, pixels, 0, Display.DISPLAY_WIDTH)
92+
return img
93+
}
94+
95+
fun getAudio(): List<IntArray> {
96+
val samples = mutableListOf<IntArray>()
97+
soundQueue.drainTo(samples)
98+
return samples
99+
}
100+
101+
fun getRegisters(): Registers {
102+
return gameboy.cpu.registers
103+
}
104+
105+
fun getRegistersObj(): eu.rekawek.coffeegb.core.cpu.Registers {
106+
return gameboy.cpu.registers
107+
}
108+
109+
fun getSP(): Int {
110+
return gameboy.cpu.registers.sp
111+
}
112+
113+
fun getByte(address: Int): Int {
114+
return gameboy.addressSpace.getByte(address)
115+
}
116+
117+
fun pressButton(button: Button) {
118+
eventBus.post(ButtonPressEvent(button))
119+
}
120+
121+
fun releaseButton(button: Button) {
122+
eventBus.post(ButtonReleaseEvent(button))
123+
}
124+
125+
fun disassemble(address: Int): String {
126+
val mmu = gameboy.addressSpace
127+
val opcode1 = mmu.getByte(address)
128+
if (opcode1 == 0xcb) {
129+
val opcode2 = mmu.getByte(address + 1)
130+
val opcode = Opcodes.EXT_COMMANDS[opcode2]
131+
return String.format("%04X: CB %02X %s", address, opcode2, opcode?.label ?: "UNKNOWN")
132+
} else {
133+
val opcode = Opcodes.COMMANDS[opcode1]
134+
var label = opcode?.label ?: "UNKNOWN"
135+
val length = opcode?.operandLength ?: 0
136+
val bytes = mutableListOf<String>()
137+
bytes.add(String.format("%02X", opcode1))
138+
if (length >= 1) {
139+
val v = mmu.getByte(address + 1)
140+
bytes.add(String.format("%02X", v))
141+
label = label.replace("d8", String.format("%02X", v))
142+
label = label.replace("r8", String.format("%02X", v))
143+
label = label.replace("a8", String.format("%02X", v))
144+
}
145+
if (length >= 2) {
146+
val v1 = mmu.getByte(address + 1)
147+
val v2 = mmu.getByte(address + 2)
148+
val v = v2 shl 8 or v1
149+
bytes.add(String.format("%02X", v2))
150+
label = label.replace("d16", String.format("%04X", v))
151+
label = label.replace("a16", String.format("%04X", v))
152+
}
153+
return String.format("%04X: %-11s %s", address, bytes.joinToString(" "), label)
154+
}
155+
}
156+
157+
fun getMemory(address: Int, length: Int): IntArray {
158+
val data = IntArray(length)
159+
for (i in 0 until length) {
160+
data[i] = gameboy.addressSpace.getByte(address + i)
161+
}
162+
return data
163+
}
164+
165+
fun writeMemory(address: Int, value: Int) {
166+
gameboy.addressSpace.setByte(address, value)
167+
}
168+
169+
fun isCpuHalted(): Boolean {
170+
return gameboy.cpu.state == eu.rekawek.coffeegb.core.cpu.Cpu.State.HALTED
171+
}
172+
173+
fun isCpuStopped(): Boolean {
174+
return gameboy.cpu.state == eu.rekawek.coffeegb.core.cpu.Cpu.State.STOPPED
175+
}
176+
177+
fun getCpuState(): String {
178+
return gameboy.cpu.state.name
179+
}
180+
181+
fun getCpuClockCycle(): Int {
182+
// Use reflection since clockCycle is private
183+
val field = gameboy.cpu.javaClass.getDeclaredField("clockCycle")
184+
field.isAccessible = true
185+
return field.get(gameboy.cpu) as Int
186+
}
187+
188+
fun isImeEnabled(): Boolean {
189+
val field = gameboy.javaClass.getDeclaredField("interruptManager")
190+
field.isAccessible = true
191+
val im = field.get(gameboy) as eu.rekawek.coffeegb.core.cpu.InterruptManager
192+
return im.isIme
193+
}
194+
195+
fun getIF(): Int {
196+
val field = gameboy.javaClass.getDeclaredField("interruptManager")
197+
field.isAccessible = true
198+
val im = field.get(gameboy) as eu.rekawek.coffeegb.core.cpu.InterruptManager
199+
return im.getByte(0xff0f)
200+
}
201+
202+
fun getIE(): Int {
203+
val field = gameboy.javaClass.getDeclaredField("interruptManager")
204+
field.isAccessible = true
205+
val im = field.get(gameboy) as eu.rekawek.coffeegb.core.cpu.InterruptManager
206+
return im.getByte(0xffff)
207+
}
208+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package eu.rekawek.coffeegb.controller
2+
3+
import java.io.File
4+
import javax.imageio.ImageIO
5+
6+
fun main() {
7+
val romFile = File("core/src/test/resources/roms/blargg/cpu_instrs.gb")
8+
if (!romFile.exists()) {
9+
println("ROM not found: ${romFile.absolutePath}")
10+
return
11+
}
12+
13+
val agent = Agent(romFile)
14+
println("ROM loaded: ${romFile.name}")
15+
16+
// Run for 100 frames
17+
println("Running for 100 frames...")
18+
repeat(100) {
19+
agent.runUntilFrame()
20+
if (it % 10 == 0) {
21+
val registers = agent.getRegisters()
22+
println("Frame $it: PC=${String.format("%04X", registers.pc)}, instruction: ${agent.disassemble(registers.pc)}")
23+
}
24+
}
25+
26+
val frame = agent.getFrame()
27+
if (frame != null) {
28+
val outputFile = File("screenshot.png")
29+
ImageIO.write(frame, "png", outputFile)
30+
println("Screenshot saved to ${outputFile.absolutePath}")
31+
} else {
32+
println("No frame captured")
33+
}
34+
}

0 commit comments

Comments
 (0)