Ghosts GC+ 2.4 firmware compatible with GCVideo 3.1 - Fujiflex

Luigid

.
Joined
Sep 24, 2017
Messages
98
Likes
81
Hi guys, I was tinkering with my portable again when I wondered if, after all these years, I could finally break down the compatibility barrier between GC+ and GCVideo. I started by modifying the GC+ and actually had some success—I managed to open the OSD menu (using the L2 button injection combo), but couldn't do anything else. So, I decided to tackle the second major challenge: setting up the virtual environment and compiler for the GCVideo FPGA. The initial tests went much worse than the GC+ side of things... I went back and forth between the two without much luck, until I decided to search online for something conceptually relevant to the work I was doing. I studied how the GameCube controller communicates, the timing, and so on. I returned to the GC+ and finally managed to navigate the menu; it’s not perfect—you have to press the button multiple times or hold it down—but it works. It’s a bit clunky, but it’s better to be able to use it with a few quirks than to have it completely dead or non-functional.
I’m attaching some test results I had saved; it seemed worth sharing them for anyone interested in the details—specifically, what modifications are needed in GC+—as well as for those who just want the latest compiled firmware (@Aurelio , let me know if this is an issue and I’ll delete it).
Since these are GC+ side modifications, there could potentially be issues with the games themselves, but for now, everything seems to be working correctly, just as before. Naturally, use it with care—it still needs thorough testing.
In the same frame of mind as Aurelio after the hours that had passed:
2026-09-13_01h46_12.webp

Getting the GCVideo OSD working with a GC+2.0 (Fujiflex) — Technical Report

## Summary

On a portable based on [Fujiflex] - (GCVideo) featuring controller emulation via [GCPlus-2.0], the L+R+X+Y button combination used to open the GCVideo OSD menu failed to work. This is a known issue that has been documented for years without a public solution.

**Identified cause:** The bit period generated by the GC+2.0 during transmission (`T2PR` in `si.c`, which drives the CLC/SPI clock used for output) is not optimal for sampling by the GCVideo `PadReader.vhd` module, even though it is "correct" according to GameCube protocol documentation (4µs/bit, as confirmed by [Jeff Longo] using a logic analyzer). Slightly compressing the period compensates for a sampling bias specific to this HDL synthesis.

**Fix:** In `si.c`, change `T2PR` from `0x1F` to `0x1B` within `SIConfigureCLC()`, **combined with** a reorganization of the main loop to minimize and stabilize the command-to-response latency (see the dedicated section below). Both modifications are required together; changing `T2PR` alone, while using the stock main loop, is insufficient.
case SI_CMD_POLL:
{
// --- GESTORE ESCLUSIVO SU MAIN.C: RITARDO 3 SECONDI + CASCATA OSD + RILASCIO ---
static uint16_t z2HoldFrames = 0;
static uint16_t timerCascata = 0;
static uint8_t rampaL = 0;
static uint8_t rampaR = 0;
static uint8_t bloccoTasto = 0;

// 1. IL TIMER DEI 3 SECONDI REALI (180 frame Joybus x 16ms = ~3000ms)
if (!inBut.Z2) { // Se tieni premuto L2 (Z2 a massa)
if (!bloccoTasto) {
if (z2HoldFrames < 200) {
z2HoldFrames++;
}
if (z2HoldFrames >= 180) { // Raggiunti i 3 secondi completi
timerCascata = 1; // Fa partire la cascata di sblocco
bloccoTasto = 1; // Blocca per evitare reset continui
}
}
} else {
// Non appena sollevi il dito da L2, azzera istantaneamente tutti i contatori
z2HoldFrames = 0;
bloccoTasto = 0;
}

// 2. L'INIETTORE DELLA CASCATA A 4 STEP (Fase Sveglia-OSD)
if (timerCascata > 0) {
timerCascata++;

// Prepariamo l'array statico isolando totalmente gli stick al centro (128)
static uint8_t cascataMsg[8] = {
0x00, // byte 0: Gestito a passi
0x80, // byte 1: Bit errore (0x80) fisso, digitali a passi
0x80, 0x80, // byte 2 e 3: Stick Principale neutro (128)
0x80, 0x80, // byte 4 e 5: C-Stick neutro (128)
0x00, 0x00 // byte 6 e 7: Trigger analogici
};

// STEP 1 (Subito dopo i 3 secondi): Premi e mantieni X
if (timerCascata >= 1) cascataMsg[0] |= 0x04;

// STEP 2 (Dopo ~250ms): Aggiungi e mantieni Y
if (timerCascata >= 15) cascataMsg[0] |= 0x08;

// STEP 3 (Dopo ~500ms): Salita progressiva e click di L
if (timerCascata >= 30) {
if (rampaL < 240) rampaL += 16;
else { rampaL = 255; cascataMsg[1] |= 0x40; }
cascataMsg[6] = rampaL;
}

// STEP 4 (Dopo ~750ms): Salita progressiva e click di R
if (timerCascata >= 45) {
if (rampaR < 240) rampaR += 16;
else { rampaR = 255; cascataMsg[1] |= 0x20; }
cascataMsg[7] = rampaR;
}

// Invia il pacchetto della cascata progressiva a GCVideo
SISendMessage(cascataMsg, 8);

// Mantieni la combo totale al massimo per altri 400ms (fino a 75 frame), poi spegni tutto!
if (timerCascata >= 75) {
timerCascata = 0;
rampaL = 0;
rampaR = 0;
// Ripristina l'array per il prossimo utilizzo futuro
cascataMsg[0] = 0x00; cascataMsg[1] = 0x80;
cascataMsg[6] = 0x00; cascataMsg[7] = 0x00;
}
}
else {
// 3. MODALITÀ STANDARD (NAVIGAZIONE OSD / GIOCO)
// Quando l'iniettore è spento (ovvero subito dopo l'apertura del menu o quando giochi),
// il main invia il flusso dinamico originale di buttons.c.
// La linea è libera, i tasti della combo sono rilasciati e il D-pad risponderà!
SISendMessage(buttonsGetMessage(cmd[1], config.triggersMode), 8);

switch (cmd[2]) {
case 1: rumbleSpin(config.rumbleIntensity); break;
case 2: rumbleBrake(); break;
default: rumbleStop(); break;
}
}
}
break;
Original symptoms:

- Holding L+R+X+Y on the GC+2.0 never triggered the GCVideo OSD—neither with a quick press nor a long hold (even 3+ seconds).
- The exact same input, when read by RVLoader/homebrew (using the Wii's standard SI stack rather than GCVideo's dedicated sniffer), always registered correctly.
- An original GameCube controller, used with the same GCVideo unit, opened the OSD normally on the first attempt.

## Chain analysis (VHDL + GCVideo firmware)

GCVideo does not read the controller via the console's standard SI bus; instead, it uses a dedicated module (`PadReader.vhd`) that passively sniffs the data line using its own timeout mechanism based on clock cycle counts:
vhdl - Padreader
constant TimeoutReply : natural := 2047; -- initial wait (command -> response)
constant TimeoutEnd : natural := 700; -- end of packet once response begins
constant SamplePoint : natural := 100; -- sampling point for each bit


A comment in the source code explicitly confirms: *"GC+ has a large delay from query to response, so the timeout is now split..."* — the GC+'s command-to-response delay is a known issue to the GCVideo author as well.

The GCVideo firmware (`pad.c`, running on a ZPU CPU embedded within the FPGA) then applies an additional stability check:
C
uint32_t curdata = paddata[1] & PAD_BUTTON_MASK;
if (curdata != prev_data) { // requires 2 identical consecutive polls
prev_data = curdata;
return;
}

...plus a gate that processes only one packet per video frame (~60Hz), tied to `getticks()`.

## Hypotheses tested and discarded

During the investigation, the following leads were systematically verified and **all ruled out** via direct testing (measuring `PADREADER->bits`/`paddata[]` using on-screen debugging on a custom-compiled GCVideo build):

1. **Packet content** — direct injection of L+R+X+Y/D-pad bits, bypassing GC+ debouncing: the button combo worked (with sustained repetition), but the D-pad never did.
2. **"Clean" vs. abrupt release** — progressive release ramps on analog axes: no effect.
3. **GC+-side debouncing** — verified not to be the cause (the D-pad works perfectly everywhere except in the OSD).
4. **GCVideo cross-debounce filter** (`prev_data`) — removed: no improvement.
5. **"One packet per video frame" gate** — removed: no improvement.
6. **Deglitcher.vhd** (noise-filtering front-end, `CompareBits`) — increased from 2 to 6: no change in measured values, ruling out ambient electrical noise as the cause.
7. **Bit-banging the transmission** inspired by [ProCon-GCC-PIC18]: introduced new instabilities (corrupted ADC readings) without solving the underlying issue; abandoned.
8. **`T6PR`** (receive command-end timeout, 5µs) — any reduction, however slight (e.g., 4.5µs), completely breaks command reception; **must not be altered**, as the 5µs margin is already at the structural minimum.

## The root cause and the fix
IMG_20260910_082941.webp
IMG_20260912_130200.webp

By measuring `PADREADER->bits` using instrumented GCVideo firmware, it was observed that **at idle, the packet is consistently truncated at 9–17 bits** (well before the full command, which requires 25 bits)—too early to be a timeout issue, and too consistent to be random noise (confirmed by (6); the Deglitcher made no difference)


C
void SIConfigureCLC(void) {
...
T2PR = 0x1B; // was 0x1F — slightly reduces the generated bit period
...
}

**Experimentally verified margins:**
  • - `0x1B` (27 counts, ~1.75µs base unit): **stable**; OSD opening and navigation work.
  • - `0x1A`: borderline; behavior unreliable.
  • - Below `0x1A`: complete communication failure.
  • - Above `0x1F` (original value, theoretically "correct" according to the protocol): the OSD never recognizes the packet.

Note: the original value (`0x1F`) corresponds exactly to the 4µs/bit documented as the protocol standard by Jeff Longo—it was not a design flaw in the GC+2.0. The fix most likely compensates for a sampling bias specific to the HDL synthesis of `PadReader.vhd` on this particular GCVideo firmware/board, rather than an error in the protocol itself.

## Reordering the main loop (necessary, not optional)

Experimentally verified: **reverting to a completely stock `main.c` (original loop, with `T2PR=0x1B` still present in `si.c`) causes the OSD to stop opening**—even when using the physical L+R+X+Y combo. `T2PR` alone is not enough.

Explanation: `T2PR` corrects the *duration* of each transmitted bit but does not eliminate variability regarding *when* transmission begins relative to the received command. In the stock loop, before responding, the firmware proceeds from port reading → `buttonsUpdate()` (debounce) → rumble handling; each step introduces a variable delay that translates into phase jitter when restarting Timer2 (which generates the transmission clock). `T2PR=0x1B` compensates for GCVideo's sampling bias only when this startup phase is reasonably consistent.

The necessary reordering within the main `while(1)` loop:
1. `SIGetCommand()` checked first, prior to port reads/`buttonsUpdate()`.
2. For `SI_CMD_POLL`: `SISendMessage()` called **before** handling rumble (`rumbleSpin`/`rumbleBrake`/`rumbleStop`), not after.

## Final Result

With **both** these changes (`T2PR = 0x1B` in `si.c` + main loop reordering; the rest of the firmware remains stock GC+2.0):

- The OSD opens reliably using either the physical L+R+X+Y combo or via emulation.
- D-pad/X/Y navigation works ("semi-smooth" quality: a second press is occasionally required, likely due to a residual timing margin not yet isolated).

No changes on the GCVideo side (HDL or firmware) proved necessary for the definitive fix—all modifications explored there were ultimately reverted.

## Possible Future Developments

- Isolate the last bit of "mushiness" in navigation (likely residual micro-jitter during the Timer2 restart phase in `SISendMessage()`, or during the `while(!SPI1STATUSbits.TXBE)` wait). - Determine whether other GC+2.0/GCVideo units require the exact same `T2PR` value, or if the sampling bias varies from board to board (in which case, `T2PR` might be better implemented as an EEPROM-configurable parameter rather than a fixed constant in the firmware).
- An RP2040/PIO-based implementation (offering cycle-perfect hardware timing) remains an alternative path that has not been fully explored; it was considered but ultimately discarded due to the scope of the required changes (specifically, a PCB + FW redesign).

## Acknowledgments

Thanks to the reverse engineering work by Jeff Longo on the GameCube/N64 protocol, and—of course—to the work of Ingo Korb (GCVideo) ,@Aurelio (GCPlus-2.0), whose open-source projects made this investigation possible , @YveltalGriffin for Fujiflex.
 

Attachments

Cool stuff!

I always assumed this was either a turnaround time or jitter issue. I'm confused at why adjusting the pulse period was part of the fix though?

I'm curious if this was an LLM assisted fix, or just the writeup was?
 
Last edited:
I looked into this further - it definitely is a turnaround time issue.

Essentially this is a problem with GC+ sometimes replying "too slowly" (for GCVideo) to joybus commands. GCVIdeo snoops on complete command/response transactions, and "closes" a transaction after 38us of silence, so a command/response turnaround time bigger than 38us splits the command and the response into two separate packets, which GCVideo ends up discarding

Changing the pulse period *just so happens* to reduce the response turnaround time, so GCVIdeo stops discarding it

I'm guessing the reason reducing the pulse period had a presumably meaningful impact on GC+'s response time, is that clocking out the 8-byte response (which main() busy-waits on completing) is now faster, and so GC+ can get back to the rest of the work in the main loop quicker - including processing the next pending command if it is ready in the buffer

The other change (loop reorder) is a rounding error by comparison, sending before the rumble handling saves (i would guess) 1-2us, and moving the command check to the top of the loop does nothing at all, since the interval between checks is the same wherever it sits.

For comparison, OEM controllers have a ~3.5us turnaround time from command to response:
 
I looked into this further - it definitely is a turnaround time issue.

Essentially this is a problem with GC+ sometimes replying "too slowly" (for GCVideo) to joybus commands. GCVIdeo snoops on complete command/response transactions, and "closes" a transaction after 38us of silence, so a command/response turnaround time bigger than 38us splits the command and the response into two separate packets, which GCVideo ends up discarding

Changing the pulse period *just so happens* to reduce the response turnaround time, so GCVIdeo stops discarding it

I'm guessing the reason reducing the pulse period had a presumably meaningful impact on GC+'s response time, is that clocking out the 8-byte response (which main() busy-waits on completing) is now faster, and so GC+ can get back to the rest of the work in the main loop quicker - including processing the next pending command if it is ready in the buffer

The other change (loop reorder) is a rounding error by comparison, sending before the rumble handling saves (i would guess) 1-2us, and moving the command check to the top of the loop does nothing at all, since the interval between checks is the same wherever it sits.

For comparison, OEM controllers have a ~3.5us turnaround time from command to response:
Hi, that makes sense and actually matches what I found. To be precise about the numbers: I tested with T2PR=0x1B alone on a fully stock main.c (rumble handled before the response, like the original code) — and the OSD combo stopped opening entirely, every time.
I think this lines up with your explanation rather than contradicting it. You're right that reordering the command check itself does nothing, and that sending before rumble only saves ~1-2us — but if the total turnaround with T2PR alone sits just barely under the 38us cutoff, that extra 1-2us from handling rumble first is enough to push it back over. Since it's a hard threshold, the practical result is binary (always works / never works) even though the underlying effect is small in absolute terms — so both changes still needed to be kept together in my case.
So to summarize for anyone trying this later: T2PR reduction is the real fix, but sending the response before handling rumble is a small-but-necessary companion change, at least on my board — not just a rounding error to skip.
 
I don't really understand why reducing T2PR fixes it tbh, if effectively shortens the bit period during GC+ -> console transmission
 
Back
Top