Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# File Watching
|
||||
|
||||
`oxide_engine::watch` watches directories on disk and emits **debounced**,
|
||||
**deduplicated** change events. It is the Stage-6 foundation for the engine's
|
||||
live-reload story:
|
||||
|
||||
- **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a
|
||||
texture or model edited in an external tool reappears in the running editor
|
||||
without restarting.
|
||||
- **Stage 10** — recompile and hot-swap game scripts using the same event
|
||||
stream and the same debounce logic.
|
||||
- **Editor** — drives the Project panel's "files appeared / disappeared"
|
||||
refresh.
|
||||
|
||||
The same module covers all of these because the hard part — "wait until the
|
||||
filesystem stops twitching, then emit one event per path" — is identical in
|
||||
every case.
|
||||
|
||||
## Why debounce
|
||||
|
||||
Filesystem events are noisy:
|
||||
|
||||
- Most editors save in several syscalls (write the file, rename a temp file
|
||||
into place, chmod) — that is one logical change but several events.
|
||||
- Recursive watches re-fire while a directory's children are being created.
|
||||
- Backends collapse or split events differently across Linux, macOS, and
|
||||
Windows.
|
||||
|
||||
If the engine reloaded on every raw event, one save could re-parse a model many
|
||||
times over. The watcher gathers raw events into a **pending set** keyed by
|
||||
path, then emits one event per path once that path has been **quiet** for a
|
||||
configurable window.
|
||||
|
||||
## Architecture (two layers)
|
||||
|
||||
The module is intentionally split so most behavior is unit-testable without
|
||||
touching real files.
|
||||
|
||||
### `Debouncer` — the pure core
|
||||
|
||||
A plain struct that takes `Instant`s from the caller. Tests drive it through a
|
||||
deterministic timeline; no `sleep`, no flaky timing dependence on the OS event
|
||||
queue.
|
||||
|
||||
```rust
|
||||
use std::time::{Duration, Instant};
|
||||
use oxide_engine::watch::{ChangeKind, Debouncer};
|
||||
|
||||
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||
let t0 = Instant::now();
|
||||
|
||||
d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
|
||||
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
|
||||
t0 + Duration::from_millis(20));
|
||||
|
||||
// Still hot — nothing fires.
|
||||
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||
|
||||
// After 100 ms of quiet, one event fires for the path.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(130));
|
||||
assert_eq!(ready.len(), 1);
|
||||
```
|
||||
|
||||
Coalescing rules (chosen to match what a reloader downstream cares about):
|
||||
|
||||
| Earlier kind | Newer kind | Emitted kind |
|
||||
|--------------|-----------|--------------|
|
||||
| `Created` | `Modified` | `Created` |
|
||||
| `Removed` | `Modified` | `Created` (file came back) |
|
||||
| anything | `Removed` | `Removed` |
|
||||
| anything else | newer | newer |
|
||||
|
||||
### `FileWatcher` — the real-world wrapper
|
||||
|
||||
Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the
|
||||
debouncer with real time and forwards settled events through an `mpsc` channel.
|
||||
|
||||
```rust,no_run
|
||||
use std::time::Duration;
|
||||
use oxide_engine::watch::FileWatcher;
|
||||
|
||||
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
watcher.watch("path/to/project/assets")?;
|
||||
|
||||
// In the editor's per-frame tick, drain whatever has settled:
|
||||
while let Ok(event) = events.try_recv() {
|
||||
println!("{:?} at {}", event.kind, event.path.display());
|
||||
}
|
||||
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
```
|
||||
|
||||
`FileWatcher` watches recursively. Dropping it stops the worker thread and
|
||||
disconnects the receiver — no manual cleanup.
|
||||
|
||||
The quiet window is a knob: too short and you get repeated events from one
|
||||
save; too long and the editor feels laggy. The default Stage-6 wiring uses
|
||||
~150 ms.
|
||||
|
||||
## Asset reload
|
||||
|
||||
`reload_changed_assets` is the wiring between the watcher and the
|
||||
[asset server](assets.md). For each `Created` or `Modified` event it calls
|
||||
`AssetServer::reload_path`, which re-runs the loader for every cached asset at
|
||||
that path and updates the existing handle **in place** — gameplay code holding
|
||||
the handle sees the new contents on its next read.
|
||||
|
||||
```rust,no_run
|
||||
use std::time::Duration;
|
||||
use oxide_engine::asset::AssetServer;
|
||||
use oxide_engine::watch::{reload_changed_assets, FileWatcher};
|
||||
|
||||
let assets = AssetServer::new();
|
||||
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
watcher.watch("path/to/project/assets")?;
|
||||
|
||||
// Per frame:
|
||||
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||
let reloaded = reload_changed_assets(&assets, batch);
|
||||
if reloaded > 0 {
|
||||
log::info!("hot-reloaded {} asset(s)", reloaded);
|
||||
}
|
||||
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
```
|
||||
|
||||
`AssetServer::reload_path` is type-erased on purpose. The cache records, per
|
||||
entry, a function pointer that re-runs the loader for that entry's concrete
|
||||
type, so the watcher can react to a disk change without knowing every asset
|
||||
type at compile time. Paths that are not currently cached return zero work;
|
||||
the next `load` picks up the fresh contents anyway. `Removed` events do **not**
|
||||
invalidate cached handles — gameplay code may want the last-loaded copy to
|
||||
keep working.
|
||||
|
||||
## What this groundwork enables
|
||||
|
||||
| Stage | Builds on |
|
||||
|-------|-----------|
|
||||
| 6 | Editor live-reload of assets; Project panel refresh |
|
||||
| 7 | Watch input-binding config for changes during a session |
|
||||
| 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) |
|
||||
| 11 | WGSL shader hot-reload |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast,
|
||||
deterministic, and they cover the coalescing rules exhaustively.
|
||||
- **One tolerant smoke test** writes to a temp dir and polls for an event with
|
||||
a generous deadline (seconds, not milliseconds). On containerized CI without
|
||||
a usable event backend the test prints `SKIP:` and passes — the unit tests
|
||||
already prove the logic is correct, this only checks the OS wiring is
|
||||
connected.
|
||||
|
||||
[`watch`]: ../engine/src/watch.rs
|
||||
[`Debouncer`]: ../engine/src/watch.rs
|
||||
[`FileWatcher`]: ../engine/src/watch.rs
|
||||
Reference in New Issue
Block a user