Skip to content

Commit d813a8c

Browse files
committed
dreamcast: align runtime assumptions with retail memory map
Use shared retail RAM bounds in the runtime and tests, and initialize the main goroutine TLS so split-stack validation matches the actual Dreamcast environment. Refresh the reference docs to explain the corrected memory layout and GC behavior. Signed-off-by: Panagiotis Georgiadis <drpaneas@gmail.com>
1 parent 5b63d10 commit d813a8c

13 files changed

Lines changed: 751 additions & 436 deletions

Makefile

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ OBJS = $(SRCS:.c=.o) runtime/runtime_sh4_minimal.o
2323

2424
all: libgodc.a libgodcbegin.a
2525

26-
# Generate struct offsets for assembly. Regenerate after changing G struct.
27-
# The offsets are verified at runtime by scheduler.c.
26+
# Generate the current asm-offsets.h placeholder artifact.
27+
# NOTE: runtime_sh4_minimal.S still uses hardcoded .equ offsets, and
28+
# scheduler.c does not verify them at runtime.
2829
runtime/asm-offsets.h: runtime/gen-offsets.c runtime/goroutine.h
2930
@echo "Generating asm-offsets.h..."
3031
@$(CC) $(CFLAGS) -S -o - $< 2>/dev/null | grep '#define' | sed 's/#define //' | \
@@ -65,18 +66,18 @@ clean:
6566
rm -f tests/*.elf tests/*.o tests/c/*.o
6667
$(MAKE) -C kos clean
6768

68-
# Verify asm-offsets.h is up-to-date with current G struct layout.
69-
# This regenerates asm-offsets.h and checks if it matches the committed version.
69+
# Verify asm-offsets.h matches the current generated placeholder output.
70+
# NOTE: This does NOT validate the hardcoded .equ offsets in
71+
# runtime/runtime_sh4_minimal.S.
7072
# Run after modifying the G struct in goroutine.h or gen-offsets.c.
7173
#
7274
# The workflow for updating G struct:
7375
# 1. Modify runtime/goroutine.h (the authoritative definition)
7476
# 2. Update runtime/gen-offsets.c to match (copy struct layout)
75-
# 3. Run 'make check-offsets' - it will fail if out of sync
76-
# 4. Run 'make runtime/asm-offsets.h' to regenerate
77-
# 5. Update runtime/runtime_sh4_minimal.S with new offsets (if G_CONTEXT changed)
78-
# 6. Run 'make check-offsets' again - should pass now
79-
# 7. The runtime also verifies at startup (scheduler.c::verify_asm_offsets)
77+
# 3. Update runtime/runtime_sh4_minimal.S with new offsets (if G_CONTEXT changed)
78+
# 4. Run 'make runtime/asm-offsets.h' to regenerate the placeholder header
79+
# 5. Run 'make check-offsets' to ensure the placeholder output matches
80+
# 6. There is currently no runtime offset verification in scheduler.c
8081
check-offsets: runtime/gen-offsets.c runtime/goroutine.h
8182
@echo "Checking asm-offsets.h synchronization..."
8283
@$(CC) $(CFLAGS) -S -o /tmp/gen-offsets-check.s runtime/gen-offsets.c 2>/dev/null && \

README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Create and run a project:
3131
```sh
3232
mkdir myproject && cd myproject
3333
godc init
34-
# write you main.go and other *.go files
34+
# write your main.go and other *.go files
3535
godc build
3636
godc run
3737
```
@@ -51,17 +51,20 @@ See the [Quick Start Guide](https://drpaneas.github.io/libgodc/getting-started/q
5151

5252
## Performance
5353

54-
Measured on real hardware (SH-4 @ 200MHz):
54+
The repo includes `tests/bench_architecture.go` for measuring runtime behavior
55+
on Dreamcast hardware.
5556

56-
| Operation | Time |
57-
|---------------------|----------|
58-
| Gosched yield | ~120 ns |
59-
| Allocation | ~186 ns |
60-
| Buffered channel | ~1.8 μs |
61-
| Context switch | ~6.4 μs |
62-
| Unbuffered channel | ~13 μs |
63-
| Goroutine spawn | ~31 μs |
64-
| GC pause | 72 μs - 6 ms |
57+
| Benchmark | What it reports |
58+
|-----------|-----------------|
59+
| `gosched` | Nanoseconds per yield |
60+
| Buffered channel | Nanoseconds per buffered operation |
61+
| Context switch | Nanoseconds per goroutine switch |
62+
| Unbuffered channel | Nanoseconds per roundtrip |
63+
| Goroutine spawn | Nanoseconds per spawn/run cycle |
64+
| GC pause | Microseconds per forced collection across retained sizes from 32 KB to 1 MB |
65+
| Memory layout | Stack size, context size, header size, and large-object threshold |
66+
67+
Run `tests/bench_architecture.elf` on your hardware for current numbers.
6568

6669
## Examples
6770

@@ -78,6 +81,7 @@ The `examples/` directory contains working programs:
7881
- `filesystem` — Directory browser
7982
- `vmu` — VMU LCD and buzzer
8083
- `brkout` — Breakout clone (GPL v2, port of Jim Ursetto's original)
84+
- `platformer` — Side-scrolling platformer
8185
- `pong` — Pong clone with 1P/2P mode, particle effects, and AI
8286

8387
## License

docs/reference/design.md

Lines changed: 459 additions & 252 deletions
Large diffs are not rendered by default.

docs/reference/effective-dreamcast-go.md

Lines changed: 65 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ These patterns come from real debugging sessions with the libgodc runtime. Follo
66

77
## Memory Model
88

9-
| Resource | Limit | Notes |
10-
|----------|-------|-------|
11-
| Total RAM | 16 MB | Shared with VRAM, sound, OS |
12-
| GC Heap | 2 MB × 2 | Semispace collector, 4MB total |
13-
| Goroutine Stack | 64 KB | Fixed size, cannot grow |
14-
| Large Object Threshold | 64 KB | Objects larger bypass GC |
9+
| Resource | Default build config | Notes |
10+
|----------|----------------------|-------|
11+
| Total RAM | 16 MB main RAM | Dreamcast system RAM budget |
12+
| GC Heap | 2 MB × 2 | Default semispace size, configurable |
13+
| Spawned Goroutine Stack | 64 KB | Default fixed size, cannot grow |
14+
| Main Goroutine Stack | 128 KB | KOS main-thread stack by default |
15+
| Large Object Threshold | 64 KB | Objects strictly larger bypass the GC heap |
1516

1617
## 1. Pre-allocate During Loading
1718

@@ -56,9 +57,12 @@ func DespawnParticle(index int) {
5657
}
5758
```
5859

59-
## 2. Respect the 64KB Stack Limit
60+
## 2. Respect the Default Stack Limits
6061

61-
Each goroutine has a fixed 64KB stack. Unlike desktop Go, stacks cannot grow. Deep recursion or large local variables will crash your game.
62+
Spawned goroutines use a fixed 64KB stack by default. Unlike desktop Go,
63+
stacks cannot grow. The main goroutine uses the KOS main-thread stack instead
64+
(128KB by default), but deep recursion or large local variables are still a
65+
bad fit for this runtime.
6266

6367
### Bad: Large local arrays
6468

@@ -150,7 +154,8 @@ func GetVisibleEnemies() []Enemy {
150154

151155
## 4. Minimize Goroutines
152156

153-
Each goroutine consumes 64KB of stack space. 100 goroutines = 6.4MB RAM—40% of total Dreamcast memory!
157+
Each spawned goroutine consumes 64KB of stack space by default. 100 spawned
158+
goroutines = 6.4MB RAM.
154159

155160
### Bad: Goroutine per entity
156161

@@ -234,7 +239,7 @@ func DrawHUD() {
234239
scoreText := fmt.Sprintf("Score: %d", score) // Allocates!
235240
DrawText(scoreText)
236241
}
237-
```c
242+
```
238243

239244
### Good: Pre-render or avoid strings
240245

@@ -254,21 +259,23 @@ func DrawScore(score int) {
254259
println("Debug:", value)
255260
```
256261

257-
## 7. Large Assets Bypass GC
262+
## 7. Large Assets Bypass the GC Heap
258263

259-
Allocations over 64KB use `malloc` directly and are **not garbage collected**.
264+
Allocations larger than 64KB use `malloc` directly and are **not garbage
265+
collected**.
260266

261267
```go
262-
// This 128KB texture is NOT managed by GC
268+
// This 128KB texture is NOT managed by the GC heap
263269
texture := make([]byte, 256*256*2)
264270

265-
// It will live forever (or until program exit)
266-
// This is usually fine - load assets once, keep forever
267-
```go
271+
// It is not freed automatically.
272+
// This is usually fine for load-once assets.
273+
```
268274

269275
Implications:
270276
- Large slices don't pressure the GC
271277
- They also don't get freed automatically
278+
- A manual free path exists via `runtime.FreeExternal`
272279
- Perfect for textures, sounds, level data
273280

274281
## 8. Escape Analysis Awareness
@@ -418,8 +425,8 @@ for i := range arr { } // Index iteration
418425
small := Vec3{1, 2, 3} // Value types
419426
make([]T, 0, capacity) // Pre-sized slices (at init)
420427
val, ok := m[key] // Safe map access
421-
select { default: } // Yield in loops
422-
runtime_checkpoint() // For panic recovery
428+
select { default: } // Yield when no case is ready
429+
runtime_checkpoint() // Establish checkpoint before deferred recover
423430
```
424431

425432
### AVOID (during gameplay)
@@ -431,49 +438,25 @@ new(T) // For small types
431438
go func() {}() // Excessive goroutines
432439
string + string // String concatenation
433440
fmt.Sprintf() // Formatted strings
434-
recover() // Use runtime_checkpoint instead
441+
recover() // Not enough without a checkpoint
435442
for { busyWork() } // Loops without yielding
436443
```
437444

438-
## 11. Panic/Recover Limitation
439-
440-
Standard Go's `recover()` does **not work** on Dreamcast due to ABI differences. Use the `runtime_checkpoint()` pattern instead:
441-
442-
### Bad: Standard recover (won't work)
443-
444-
```go
445-
func SafeCall() {
446-
defer func() {
447-
if r := recover(); r != nil { // NEVER catches panics!
448-
println("recovered")
449-
}
450-
}()
451-
panic("oops")
452-
}
453-
```
454-
455-
### Good: Use runtime_checkpoint
456-
457-
```go
458-
import _ "unsafe"
445+
## 11. Panic Recovery Is Limited
459446

460-
//go:linkname runtime_checkpoint runtime.runtime_checkpoint
461-
func runtime_checkpoint() int
447+
libgodc implements `recover()`, but resumed execution currently depends on a
448+
checkpoint established before the code that may panic. A recovered panic
449+
longjmps back to that checkpoint.
462450

463-
func SafeCall() (recovered bool) {
464-
defer func() {
465-
if runtime_checkpoint() != 0 {
466-
recovered = true
467-
return
468-
}
469-
// Normal cleanup here
470-
}()
471-
panic("oops")
472-
return false
473-
}
474-
```go
451+
Practical rules:
452+
- Plain `recover()` without a checkpoint is not enough.
453+
- Nil dereference, bounds, and divide-by-zero helpers currently go through the
454+
same panic machinery as `panic()`.
455+
- Fatal `runtime_throw()` paths and interface type-assertion panic helpers
456+
still abort immediately.
457+
- For gameplay code, avoid panic-based control flow and validate inputs early.
475458

476-
Most game code shouldn't need recover. Design to avoid panics:
459+
Most game code shouldn't need recovery. Design to avoid panics:
477460
- Check bounds before indexing
478461
- Validate inputs at entry points
479462
- Use `ok` form for map access: `val, ok := m[key]`
@@ -483,10 +466,10 @@ Most game code shouldn't need recover. Design to avoid panics:
483466
The Dreamcast scheduler is **cooperative**, not preemptive. Goroutines run until they yield.
484467

485468
### Goroutines yield when they:
486-
- Send/receive on channels
487-
- Call `select` (including with `default`)
488-
- Call explicit yield functions
489-
- Block on I/O
469+
- Block on channel operations
470+
- Call `select`/`default` when no case is ready
471+
- Call explicit yield functions such as `runtime.Gosched()`
472+
- Sleep or wait on timers
490473

491474
### Bad: Infinite loop without yielding
492475

@@ -533,7 +516,8 @@ Because of cooperative scheduling:
533516

534517
## 13. Select with Default
535518

536-
`select` with `default` is an efficient polling pattern that yields correctly:
519+
`select` with `default` is an efficient polling pattern that yields when no
520+
case is ready:
537521

538522
```go
539523
func pollChannels() {
@@ -545,7 +529,7 @@ func pollChannels() {
545529
handleResult(result)
546530
default:
547531
// No message ready - yields to other goroutines
548-
// then returns immediately
532+
// and then returns immediately
549533
}
550534

551535
// Can do other work here
@@ -563,26 +547,25 @@ This pattern works well for:
563547

564548
### Goroutine Leak
565549

566-
Dead goroutines retain ~160 bytes each (G struct only). The stack memory and
567-
TLS are properly reclaimed, and the G struct is kept in a free list for reuse
568-
by future goroutines. When you spawn a new goroutine, it reuses a G from the
569-
free list if available.
550+
The runtime contains a dead-goroutine queue and a `freegs` reuse path, but in
551+
the current source exited goroutines do not age into reclaimable state because
552+
`global_generation` is never advanced.
570553

571-
If you spawn 10,000 goroutines that all exit without spawning new ones, you'll
572-
have ~1.6MB in the free list. This memory is reused when you spawn new
573-
goroutines. Monitor goroutine count with `runtime.NumGoroutine()`.
554+
In practice, high-churn spawn/exit patterns can retain goroutine state instead
555+
of recycling it promptly. Prefer long-lived goroutines and monitor goroutine
556+
count with `runtime.NumGoroutine()`.
574557

575-
### Unrecoverable Runtime Panics
558+
### Panic Recovery Boundary
576559

577-
User `panic()` is recoverable. Runtime panics are not:
560+
`panic()` participates in the panic/recover machinery, and nil/bounds/divide
561+
helpers currently do too.
578562

579-
- Nil pointer dereference
580-
- Array/slice bounds check
581-
- Integer divide by zero
582-
- Stack overflow
563+
The hard boundary is elsewhere:
564+
- `recover()` without an earlier checkpoint is fatal
565+
- `runtime_throw()` failures abort immediately
566+
- Interface type-assertion panic helpers abort immediately
583567

584-
These crash immediately. A bounds check failure means program invariants are
585-
violated—continuing would corrupt data.
568+
Treat panic recovery as a specialized escape hatch, not a normal game-code tool.
586569

587570
### 32-bit Pointers
588571

@@ -620,8 +603,8 @@ dcache_inval_range((uintptr_t)ptr, size); // After DMA read (HW -> CPU)
620603
621604
- **reflect**: Basic type inspection only, no `reflect.MakeFunc`
622605
- **unsafe**: Works, but remember 4-byte pointers
623-
- **sync**: Mutexes work, but with M:1 scheduling no other goroutine runs
624-
while you hold a lock—deadlock is impossible but starvation is easy
606+
- **sync**: Mutexes work, but deadlocks and starvation are still possible.
607+
Avoid blocking or sleeping while holding locks.
625608
626609
### Compatibility
627610
@@ -658,12 +641,13 @@ If your game crashes:
658641
1. **Stack overflow**: Reduce recursion, shrink local arrays
659642
2. **Nil pointer**: Check slice bounds, map existence
660643
3. **GC corruption**: Ensure pointers are valid (not into freed memory)
661-
4. **Panic without checkpoint**: Use `runtime_checkpoint()` for recovery
644+
4. **Panic recovery mismatch**: Recovery is checkpoint-based; plain `recover()`
645+
is not enough
662646
663647
## Further Reading
664648
665-
- `docs/DESIGN.md` - Runtime architecture
666-
- `docs/KOS_WRAPPERS.md` - Hardware access
649+
- `docs/reference/design.md` - Runtime architecture
650+
- `docs/reference/kos-wrappers.md` - Hardware access
667651
- `examples/` - Working game examples
668652
669653
Console development is the art of saying 'no' to malloc.

0 commit comments

Comments
 (0)