Compare commits
117
Commits
we-ball
...
raw-json-maps
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd760fb2e | ||
|
|
f303d24f4a | ||
|
|
9688e88a7c | ||
|
|
c8a6325615 | ||
|
|
798db14bb5 | ||
|
|
178d78ef72 | ||
|
|
8139a0ea54 | ||
|
|
327c2bec70 | ||
|
|
fc0195d9f7 | ||
|
|
db42bca44b | ||
|
|
b3623c84a5 | ||
|
|
45c1f9004b | ||
|
|
845a395ff1 | ||
|
|
f63653c307 | ||
|
|
5b523c68a7 | ||
|
|
03efbe0681 | ||
|
|
cebd3d81e7 | ||
|
|
de53bebeb8 | ||
|
|
6ccaedd48f | ||
|
|
707856fcf2 | ||
|
|
3c3dc35e1d | ||
|
|
f9820bd226 | ||
|
|
fe2ef92ef3 | ||
|
|
9364768397 | ||
|
|
9c28d5a3b7 | ||
|
|
d5078b978c | ||
|
|
d49594d3fb | ||
|
|
619ce932f9 | ||
|
|
b564d0b1c1 | ||
|
|
fc1fc9f170 | ||
|
|
fd5bcfae90 | ||
|
|
3ee0a53688 | ||
|
|
62c081ecf2 | ||
|
|
d9beb647c1 | ||
|
|
f8f8a80a21 | ||
|
|
ac8023d50f | ||
|
|
769f2f5702 | ||
|
|
2f6839bcc4 | ||
|
|
d37109f5f7 | ||
|
|
15bd9fc43c | ||
|
|
8049e90853 | ||
|
|
e47a2c3e5c | ||
|
|
9512c22e1f | ||
|
|
8a77001016 | ||
|
|
9b0214ee55 | ||
|
|
7ad735552a | ||
|
|
ea35472ef8 | ||
|
|
50c5621d8a | ||
|
|
da275cfd52 | ||
|
|
0f4ee5d965 | ||
|
|
af8c0f5ccd | ||
|
|
8ea370a1d1 | ||
|
|
fcf0de72af | ||
|
|
320c5e6ce5 | ||
|
|
7a858cc424 | ||
|
|
c0292842a5 | ||
|
|
717902462b | ||
|
|
1d73b9d224 | ||
|
|
51f262efa0 | ||
|
|
a6e4e3f71f | ||
|
|
4387d223b9 | ||
|
|
00bfaf6360 | ||
|
|
709bd7be52 | ||
|
|
80f4348e21 | ||
|
|
e630827b34 | ||
|
|
106d9b0fc0 | ||
|
|
4a26f79945 | ||
|
|
1a199f6ce3 | ||
|
|
ec59e77867 | ||
|
|
cb28d2b611 | ||
|
|
27b6ddf5cb | ||
|
|
475c865e33 | ||
|
|
0b4ba062bb | ||
|
|
28f5e66662 | ||
|
|
e225a076f0 | ||
|
|
774c8ad0f8 | ||
|
|
52d1e7414d | ||
|
|
af4cb53e5f | ||
|
|
c019271e12 | ||
|
|
82ae2bce9d | ||
|
|
092e259a06 | ||
|
|
08b4bbfe91 | ||
|
|
560c51cf27 | ||
|
|
674f86b18a | ||
|
|
e3f10e0926 | ||
|
|
d7223d7387 | ||
|
|
45331c2a60 | ||
|
|
33f50a2c69 | ||
|
|
6c8e4d5cbd | ||
|
|
3b7215876a | ||
|
|
c74f5890bd | ||
|
|
fbd3c71ba7 | ||
|
|
128f9ab9d4 | ||
|
|
36fb359aa2 | ||
|
|
1bd73d69fe | ||
|
|
fb48285143 | ||
|
|
3de50b8370 | ||
|
|
e61cbe25b7 | ||
|
|
8395830be6 | ||
|
|
6e4ec2b9d8 | ||
|
|
f501bb8e28 | ||
|
|
d07cd3397d | ||
|
|
e008fb108a | ||
|
|
9aaffff7a8 | ||
|
|
7357b4a5df | ||
|
|
1ddc298a74 | ||
|
|
e2a9442aa6 | ||
|
|
aa0180571e | ||
|
|
7f7be39230 | ||
|
|
4d95415232 | ||
|
|
2cbd80a004 | ||
|
|
9abf8101da | ||
|
|
24badd06a5 | ||
|
|
7a03ef8eaf | ||
|
|
f3ea507313 | ||
|
|
4b0388a0e1 | ||
|
|
a84137b5ff |
@@ -107,3 +107,4 @@ yarn.lock
|
|||||||
/assets/test
|
/assets/test
|
||||||
/tools_old
|
/tools_old
|
||||||
/assets/test.png
|
/assets/test.png
|
||||||
|
/assets/audio/boa.mp3
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
# Dusk — Claude Code rules
|
|
||||||
|
|
||||||
## File headers
|
|
||||||
Every C, H, and JS file starts with:
|
|
||||||
|
|
||||||
```c
|
|
||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
JS files use `//` comment style instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C conventions
|
|
||||||
|
|
||||||
### Types
|
|
||||||
Always use the project-defined aliases instead of bare C primitives:
|
|
||||||
|
|
||||||
| Use | Not |
|
|
||||||
|-----------|--------------|
|
|
||||||
| `bool_t` | `bool` |
|
|
||||||
| `int_t` | `int` |
|
|
||||||
| `float_t` | `float` |
|
|
||||||
| `char_t` | `char` |
|
|
||||||
|
|
||||||
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
|
|
||||||
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
|
|
||||||
|
|
||||||
### Naming
|
|
||||||
- **Functions** — snake_case, prefixed with their module:
|
|
||||||
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
|
|
||||||
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
|
|
||||||
- **Macros / constants** — UPPER_SNAKE_CASE:
|
|
||||||
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
|
|
||||||
- **Files** — snake_case matching the primary type: `entityposition.c`,
|
|
||||||
`moduleassetbatch.c`
|
|
||||||
|
|
||||||
### Header files (`.h`)
|
|
||||||
- Use `#pragma once` — no include guards.
|
|
||||||
- Declare every public function, `#define`, and `extern` global.
|
|
||||||
- Write a JSDoc block (`/** … */`) above every declaration explaining
|
|
||||||
purpose, `@param`s, and `@returns`.
|
|
||||||
- Only include headers that the `.h` file itself strictly requires for
|
|
||||||
the types it exposes. Move everything else to the `.c` file.
|
|
||||||
Do not use forward declarations as a workaround — use the real
|
|
||||||
include in the `.c` file instead.
|
|
||||||
|
|
||||||
### Implementation files (`.c`)
|
|
||||||
- Contain function bodies only; no declarations.
|
|
||||||
- Pull in whatever additional includes the implementation needs.
|
|
||||||
- Do not use `static` or `inline` on **functions**. Every function,
|
|
||||||
including internal helpers, must be declared in the matching `.h` and
|
|
||||||
defined in the `.c` file. Internal helpers belong near the bottom of
|
|
||||||
the `.c` file, not at the top with a `static` qualifier.
|
|
||||||
`static` and `inline` on functions are only appropriate when the
|
|
||||||
function body is written directly inside a `.h` file.
|
|
||||||
`static` on **variables** (file-scope state) is fine and expected.
|
|
||||||
|
|
||||||
### Formatting
|
|
||||||
- Hard-wrap all lines at **80 characters**.
|
|
||||||
|
|
||||||
### Error handling
|
|
||||||
Return `errorret_t` from fallible functions. Use these macros:
|
|
||||||
|
|
||||||
```c
|
|
||||||
errorOk(); // return success
|
|
||||||
errorThrow("msg %d", val); // return failure with message
|
|
||||||
errorChain(someCall()); // propagate failure, continue on success
|
|
||||||
errorIsOk(ret) / errorIsNotOk(ret) // test a result
|
|
||||||
errorCatch(ret); // handle + free an error
|
|
||||||
```
|
|
||||||
|
|
||||||
Never return raw error codes or use `errno` for in-engine errors.
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
Use the project allocator — never raw `malloc`/`free`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
memoryAllocate(size) // allocate
|
|
||||||
memoryFree(ptr) // free
|
|
||||||
memoryZero(dest, size) // zero a block
|
|
||||||
memoryCopy(dest, src, size) // copy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Asserts
|
|
||||||
Prefer specific assert macros over bare `assert()`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
assertNotNull(ptr, "msg");
|
|
||||||
assertTrue(cond, "msg");
|
|
||||||
assertFalse(cond, "msg");
|
|
||||||
assertUnreachable("msg");
|
|
||||||
assertIsMainThread("msg");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build system
|
|
||||||
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
|
|
||||||
|
|
||||||
```cmake
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
myfile.c
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Never add source files to the root `CMakeLists.txt` directly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Platform support
|
|
||||||
|
|
||||||
### Targets
|
|
||||||
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
|
|
||||||
|
|
||||||
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|
|
||||||
|----------------------|-------------------|------------------|
|
|
||||||
| `linux` | `DUSK_LINUX` | Linux desktop |
|
|
||||||
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
|
|
||||||
| `psp` | `DUSK_PSP` | Sony PSP |
|
|
||||||
| `vita` | `DUSK_VITA` | PlayStation Vita |
|
|
||||||
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
|
|
||||||
| `wii` | `DUSK_WII` | Nintendo Wii |
|
|
||||||
|
|
||||||
### Layer structure
|
|
||||||
```
|
|
||||||
src/dusk/ core, platform-agnostic game logic
|
|
||||||
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
|
|
||||||
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
|
|
||||||
src/dusklinux/ Linux + Knulli platform impl
|
|
||||||
src/duskpsp/ PSP platform impl
|
|
||||||
src/duskvita/ Vita platform impl
|
|
||||||
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
|
|
||||||
it uses native GameCube/Wii rendering and input APIs.
|
|
||||||
|
|
||||||
### Platform guards
|
|
||||||
Use the compile-time macros for platform-specific code:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#ifdef DUSK_PSP
|
|
||||||
// PSP-only path
|
|
||||||
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
|
|
||||||
// GameCube / Wii path
|
|
||||||
#else
|
|
||||||
// Generic / Linux fallback
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
Additional capability macros set per-target:
|
|
||||||
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
|
|
||||||
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
|
|
||||||
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
|
|
||||||
|
|
||||||
### Abstraction pattern
|
|
||||||
Platform-specific implementations are wired in via `#define` macros in
|
|
||||||
each platform's `displayplatform.h` / `inputplatform.h` etc., which
|
|
||||||
the core calls through. Functions that a platform does not support are
|
|
||||||
simply left undefined — the core guards calls with `#ifdef`.
|
|
||||||
|
|
||||||
### Adding platform-specific code
|
|
||||||
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
|
|
||||||
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
|
|
||||||
or capability macro.
|
|
||||||
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
|
|
||||||
the platform header macros instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a new asset loader type
|
|
||||||
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
|
|
||||||
`src/dusk/asset/loader/assetloader.h`.
|
|
||||||
2. Add fields to the input/loading/output unions in `assetloader.h`.
|
|
||||||
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
|
|
||||||
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
|
|
||||||
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
|
|
||||||
`src/dusk/asset/loader/assetloader.c`.
|
|
||||||
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a new entity component
|
|
||||||
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
|
|
||||||
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
|
|
||||||
`entityMyCompDispose()`.
|
|
||||||
2. Add the include to `src/dusk/entity/componentlist.h` header block.
|
|
||||||
3. Add a row to `src/dusk/entity/componentlist.h`:
|
|
||||||
```c
|
|
||||||
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
|
|
||||||
```
|
|
||||||
This auto-generates the enum, union field, and definition entry.
|
|
||||||
4. If JS-facing, create the script module and `.d.ts` (see below).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a new script (JS) module
|
|
||||||
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
|
|
||||||
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
|
|
||||||
- Use `moduleBaseFunction(name)` to define JS-callable functions.
|
|
||||||
- Register props/funcs in `moduleMyModInit()` with
|
|
||||||
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
|
|
||||||
`scriptProtoDefineStaticFunc`.
|
|
||||||
2. `#include` the header in
|
|
||||||
`src/dusk/script/module/modulelist.c` and call
|
|
||||||
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
|
|
||||||
`moduleListDispose()`).
|
|
||||||
3. For component modules also register in
|
|
||||||
`src/dusk/script/module/entity/component/modulecomponentlist.c`
|
|
||||||
so `entity.add()` returns the typed wrapper.
|
|
||||||
4. Create `types/<category>/mymod.d.ts` and add a
|
|
||||||
`/// <reference path="..." />` line to `types/index.d.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Script module type declarations
|
|
||||||
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
|
|
||||||
check whether the corresponding `types/**/*.d.ts` needs updating and
|
|
||||||
apply any changes before finishing the task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## JavaScript (asset scripts)
|
|
||||||
- Use `var` for module-level state; `const` for values that never
|
|
||||||
change.
|
|
||||||
- Always use semicolons.
|
|
||||||
- Scene objects are plain objects (`var scene = {}`) with assigned
|
|
||||||
methods.
|
|
||||||
- Export via `module.exports = scene`.
|
|
||||||
- Async scene init should use `async function` and `await`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coding style
|
|
||||||
|
|
||||||
### ASCII only
|
|
||||||
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000–U+007F).
|
|
||||||
Non-ASCII characters are banned even in comments and string literals.
|
|
||||||
Use ASCII-only substitutes instead:
|
|
||||||
- `--` or `-` instead of `—` (em dash)
|
|
||||||
- `->` instead of `→` (arrow)
|
|
||||||
- `x` or `*` instead of `×` (multiplication)
|
|
||||||
|
|
||||||
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
|
|
||||||
|
|
||||||
### Indentation
|
|
||||||
2 spaces. No tabs.
|
|
||||||
|
|
||||||
### Keyword and operator spacing
|
|
||||||
No space between a keyword or function name and its opening parenthesis:
|
|
||||||
|
|
||||||
```c
|
|
||||||
if(!ptr) return;
|
|
||||||
for(uint8_t i = 0; i < count; i++) {
|
|
||||||
while(entry->state != DONE) {
|
|
||||||
switch(type) {
|
|
||||||
sizeof(assetbatch_t)
|
|
||||||
memoryZero(ptr, size)
|
|
||||||
```
|
|
||||||
|
|
||||||
Spaces around all binary operators and after every comma:
|
|
||||||
|
|
||||||
```c
|
|
||||||
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
|
|
||||||
(size_t)end - (size_t)start
|
|
||||||
foo(a, b, c)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Braces
|
|
||||||
Opening brace on the **same line** as the statement (K&R style) for all
|
|
||||||
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
void assetEntryLock(assetentry_t *entry) {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
if(dirty) {
|
|
||||||
...
|
|
||||||
} else {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Guard returns
|
|
||||||
Short guards go on one line with no braces:
|
|
||||||
|
|
||||||
```c
|
|
||||||
if(!ptr) return;
|
|
||||||
if(!b || !b->batch) return jerry_undefined();
|
|
||||||
if(!(flags & DIRTY)) return;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blank lines
|
|
||||||
- One blank line between functions; no blank line at the start or end of
|
|
||||||
a function body.
|
|
||||||
- One blank line between logical blocks inside a function body.
|
|
||||||
- No trailing blank lines at the end of a file.
|
|
||||||
|
|
||||||
### Pointer placement
|
|
||||||
`*` is attached to the variable name, not the type:
|
|
||||||
|
|
||||||
```c
|
|
||||||
assetentry_t *entry
|
|
||||||
const char_t *name
|
|
||||||
void *ptr
|
|
||||||
uint8_t *d = (uint8_t *)dest;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Casts
|
|
||||||
Space between cast and operand:
|
|
||||||
|
|
||||||
```c
|
|
||||||
(assetbatch_t *)user
|
|
||||||
(uint8_t *)dest
|
|
||||||
(textureformat_t)v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Return
|
|
||||||
No parentheses around the return value:
|
|
||||||
|
|
||||||
```c
|
|
||||||
return ptr;
|
|
||||||
return MEMORY_POINTERS_IN_USE;
|
|
||||||
```
|
|
||||||
|
|
||||||
### switch / case
|
|
||||||
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
switch(type) {
|
|
||||||
case ASSET_LOADER_TYPE_TEXTURE:
|
|
||||||
descs[i].input.texture = (textureformat_t)v;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-line function signatures
|
|
||||||
When parameters don't fit on one line, put each on its own line indented
|
|
||||||
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
|
|
||||||
its own line at column 0:
|
|
||||||
|
|
||||||
```c
|
|
||||||
void assetEntryInit(
|
|
||||||
assetentry_t *entry,
|
|
||||||
const char_t *name,
|
|
||||||
const assetloadertype_t type,
|
|
||||||
assetloaderinput_t *input
|
|
||||||
) {
|
|
||||||
|
|
||||||
errorret_t memoryCompare(
|
|
||||||
const void *a,
|
|
||||||
const void *b,
|
|
||||||
const size_t size
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Structs and enums
|
|
||||||
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
|
|
||||||
brace and name on the same line:
|
|
||||||
|
|
||||||
```c
|
|
||||||
typedef struct {
|
|
||||||
errorcode_t code;
|
|
||||||
char_t *message;
|
|
||||||
} errorstate_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
ASSET_LOADER_TYPE_NULL,
|
|
||||||
ASSET_LOADER_TYPE_COUNT
|
|
||||||
} assetloadertype_t;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Designated initialisers
|
|
||||||
Spaces inside braces; `.field = value`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
jsassetentry_t e = { .entry = entry };
|
|
||||||
assetbatchloadedpend_t init = { .batch = batch };
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ternary operator
|
|
||||||
Spaces around `?` and `:`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
|
|
||||||
```
|
|
||||||
|
|
||||||
### const placement
|
|
||||||
`const` before the type, `*` attached to the variable:
|
|
||||||
|
|
||||||
```c
|
|
||||||
const char_t *name
|
|
||||||
const void *src
|
|
||||||
const size_t size
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comments in `.c` files
|
|
||||||
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
|
|
||||||
functions follow one another with a single blank line between them.
|
|
||||||
- Multi-line explanatory comments inside function bodies use `//` lines:
|
|
||||||
```c
|
|
||||||
// Script modules are freed; orphaned JS wrapper objects now get GC'd
|
|
||||||
// so their finalizers fire before assetDispose() checks ref counts.
|
|
||||||
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
|
|
||||||
```
|
|
||||||
- Do not use `/* */` for inline or inline-block comments inside `.c`
|
|
||||||
function bodies.
|
|
||||||
|
|
||||||
### Comments in `.h` files
|
|
||||||
Every public declaration gets a Javadoc block (`/** … */`) with
|
|
||||||
`@param` and `@returns` where relevant. Keep it on the lines immediately
|
|
||||||
above the declaration with no blank line in between.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color system
|
|
||||||
|
|
||||||
Colors are defined in `src/dusk/display/color.csv` and code-generated
|
|
||||||
into a `color.h` header by `tools/color/csv/__main__.py`.
|
|
||||||
|
|
||||||
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
|
|
||||||
The script emits four `#define` variants per color plus a bare alias:
|
|
||||||
|
|
||||||
```
|
|
||||||
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
|
|
||||||
COLOR_<NAME>_3B color3b(r8, g8, b8)
|
|
||||||
COLOR_<NAME>_3F color3f(rf, gf, bf)
|
|
||||||
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
|
|
||||||
COLOR_<NAME> COLOR_<NAME>_4B
|
|
||||||
```
|
|
||||||
|
|
||||||
`color_t` is `color4b_t` (four `uint8_t` channels).
|
|
||||||
|
|
||||||
To add a new color, append a row to `color.csv` and rebuild — do not
|
|
||||||
hand-edit the generated header.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Tests live in `test/` mirroring `src/dusk/` structure.
|
|
||||||
- Use cmocka; include `dusktest.h`.
|
|
||||||
- Test functions: `static void test_something(void **state)`.
|
|
||||||
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
|
|
||||||
leaks.
|
|
||||||
- Build with `-DDUSK_BUILD_TESTS=ON`.
|
|
||||||
+40
-15
@@ -8,16 +8,22 @@ cmake_minimum_required(VERSION 3.13)
|
|||||||
|
|
||||||
set(CMAKE_C_STANDARD 11)
|
set(CMAKE_C_STANDARD 11)
|
||||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules)
|
set(CMAKE_MODULE_PATH
|
||||||
|
${CMAKE_MODULE_PATH}
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules
|
||||||
|
)
|
||||||
cmake_policy(SET CMP0079 NEW)
|
cmake_policy(SET CMP0079 NEW)
|
||||||
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
# set(FETCHCONTENT_UPDATES_DISCONNECTED ON)
|
||||||
|
|
||||||
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
option(DUSK_BUILD_TESTS "Enable tests" OFF)
|
||||||
|
option(DUSK_NETWORK "Enable network support" ON)
|
||||||
|
|
||||||
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
set(DUSK_GAME_NAME "Dusk" CACHE STRING "Game display name")
|
||||||
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
set(DUSK_GAME_AUTHOR "YourWishes" CACHE STRING "Game author / coder")
|
||||||
set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description")
|
set(DUSK_GAME_SHORT_DESCRIPTION "Dusk game" CACHE STRING "One-line description")
|
||||||
set(DUSK_GAME_LONG_DESCRIPTION "No description yet." CACHE STRING "Full description")
|
set(DUSK_GAME_LONG_DESCRIPTION
|
||||||
|
"No description yet." CACHE STRING "Full description"
|
||||||
|
)
|
||||||
|
|
||||||
# Prep cache
|
# Prep cache
|
||||||
set(DUSK_CACHE_TARGET "dusk-target")
|
set(DUSK_CACHE_TARGET "dusk-target")
|
||||||
@@ -31,13 +37,19 @@ set(DUSK_TEMP_DIR "${DUSK_BUILD_DIR}/temp")
|
|||||||
set(DUSK_TOOLS_DIR "${DUSK_ROOT_DIR}/tools")
|
set(DUSK_TOOLS_DIR "${DUSK_ROOT_DIR}/tools")
|
||||||
set(DUSK_DATA_DIR "${DUSK_ROOT_DIR}/data")
|
set(DUSK_DATA_DIR "${DUSK_ROOT_DIR}/data")
|
||||||
set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets")
|
set(DUSK_ASSETS_DIR "${DUSK_ROOT_DIR}/assets")
|
||||||
set(DUSK_BUILT_ASSETS_DIR "${DUSK_BUILD_DIR}/built_assets" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_BUILT_ASSETS_DIR "${DUSK_BUILD_DIR}/built_assets"
|
||||||
|
CACHE INTERNAL ${DUSK_CACHE_TARGET}
|
||||||
|
)
|
||||||
set(DUSK_GENERATED_HEADERS_DIR "${DUSK_BUILD_DIR}/generated")
|
set(DUSK_GENERATED_HEADERS_DIR "${DUSK_BUILD_DIR}/generated")
|
||||||
set(DUSK_BUILD_BINARY ${DUSK_BUILD_DIR}/Dusk CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_BUILD_BINARY ${DUSK_BUILD_DIR}/Dusk
|
||||||
|
CACHE INTERNAL ${DUSK_CACHE_TARGET}
|
||||||
|
)
|
||||||
set(DUSK_ASSETS "" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_ASSETS "" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
||||||
set(DUSK_LIBRARY_TARGET_NAME "DuskCore" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_LIBRARY_TARGET_NAME "DuskCore" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
||||||
set(DUSK_BINARY_TARGET_NAME "Dusk" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_BINARY_TARGET_NAME "Dusk" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
||||||
set(DUSK_ASSETS_ZIP "${DUSK_BUILD_DIR}/dusk.dsk" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_ASSETS_ZIP "${DUSK_BUILD_DIR}/dusk.dsk"
|
||||||
|
CACHE INTERNAL ${DUSK_CACHE_TARGET}
|
||||||
|
)
|
||||||
|
|
||||||
if(NOT DEFINED DUSK_TARGET_SYSTEM)
|
if(NOT DEFINED DUSK_TARGET_SYSTEM)
|
||||||
set(DUSK_TARGET_SYSTEM "linux")
|
set(DUSK_TARGET_SYSTEM "linux")
|
||||||
@@ -72,7 +84,9 @@ if(DUSK_BUILD_TESTS)
|
|||||||
${DUSK_LIBRARY_TARGET_NAME}
|
${DUSK_LIBRARY_TARGET_NAME}
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}" CACHE INTERNAL ${DUSK_CACHE_TARGET})
|
set(DUSK_LIBRARY_TARGET_NAME "${DUSK_BINARY_TARGET_NAME}"
|
||||||
|
CACHE INTERNAL ${DUSK_CACHE_TARGET}
|
||||||
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(NOT DEFINED DUSK_VERSION)
|
if(NOT DEFINED DUSK_VERSION)
|
||||||
@@ -90,6 +104,12 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME}
|
|||||||
DUSK_VERSION="${DUSK_VERSION}"
|
DUSK_VERSION="${DUSK_VERSION}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(DUSK_NETWORK)
|
||||||
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_NETWORK
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Toolchains
|
# Toolchains
|
||||||
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
|
include(cmake/targets/${DUSK_TARGET_SYSTEM}.cmake)
|
||||||
|
|
||||||
@@ -115,16 +135,21 @@ if(DUSK_BUILD_TESTS)
|
|||||||
add_subdirectory(test)
|
add_subdirectory(test)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Build assets
|
# Build assets. Runs unconditionally on every build (rather than being
|
||||||
file(GLOB_RECURSE DUSK_ASSET_FILES CONFIGURE_DEPENDS "${DUSK_ASSETS_DIR}/*")
|
# gated behind a custom_command's file-level DEPENDS list) because
|
||||||
add_custom_command(
|
# CONFIGURE_DEPENDS globs only pick up added/removed files on the next
|
||||||
OUTPUT "${DUSK_ASSETS_ZIP}"
|
# CMake configure, so that approach could miss/lag a rebuild behind an
|
||||||
|
# asset edit. tools.asset.pack itself decides whether anything actually
|
||||||
|
# changed (see its manifest check) and no-ops quickly when nothing did,
|
||||||
|
# so this stays cheap on a build where assets are untouched.
|
||||||
|
add_custom_target(DUSK_ASSETS_BUILT ALL
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
|
COMMAND ${CMAKE_COMMAND} -E make_directory "${DUSK_ASSETS_DIR}"
|
||||||
COMMAND ${CMAKE_COMMAND} -E rm -f "${DUSK_ASSETS_ZIP}"
|
COMMAND ${Python3_EXECUTABLE} -m tools.asset.pack
|
||||||
COMMAND ${CMAKE_COMMAND} -E tar "cf" "${DUSK_ASSETS_ZIP}" --format=zip -- .
|
--input "${DUSK_ASSETS_DIR}"
|
||||||
WORKING_DIRECTORY "${DUSK_ASSETS_DIR}"
|
--output "${DUSK_ASSETS_ZIP}"
|
||||||
DEPENDS ${DUSK_ASSET_FILES}
|
WORKING_DIRECTORY "${DUSK_ROOT_DIR}"
|
||||||
|
BYPRODUCTS "${DUSK_ASSETS_ZIP}"
|
||||||
|
COMMENT "Packing assets into dusk.dsk"
|
||||||
VERBATIM
|
VERBATIM
|
||||||
)
|
)
|
||||||
add_custom_target(DUSK_ASSETS_BUILT DEPENDS "${DUSK_ASSETS_ZIP}")
|
|
||||||
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
|
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} DUSK_ASSETS_BUILT)
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ following packages, depending on your system;
|
|||||||
|
|
||||||
Fedora;
|
Fedora;
|
||||||
```
|
```
|
||||||
sudo dnf install git make gcc cmake python python-polib python3-pillow python3-dotenv python3-numpy python-qt5 python3-pyopengl SDL2-devel zlib-devel libzip-devel bzip2-devel openssl-devel lzma-sdk-devel xz xz-devel lua-devel
|
sudo dnf install git make gcc cmake python python-polib python3-pillow python3-dotenv python3-numpy python-qt5 python3-pyopengl SDL2-devel zlib-devel libzip-devel bzip2-devel openssl-devel lzma-sdk-devel xz xz-devel lua-devel libmad-devel
|
||||||
```
|
```
|
||||||
|
`libmad-devel` (used for MP3 decoding) is GPL-licensed and ships in the
|
||||||
|
[RPM Fusion](https://rpmfusion.org/) free repository rather than Fedora's
|
||||||
|
base repos - make sure RPM Fusion is enabled first.
|
||||||
|
|
||||||
Ubuntu;
|
Ubuntu;
|
||||||
```
|
```
|
||||||
sudo apt-get install git build-essential gcc python python-polib python3-pillow python3-dotenv python3-numpy python3-pyqt5 python3-opengl
|
sudo apt-get install git build-essential gcc python python-polib python3-pillow python3-dotenv python3-numpy python3-pyqt5 python3-opengl libmad0-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Arch Linux;
|
Arch Linux;
|
||||||
```
|
```
|
||||||
sudo pacman -S git base-devel gcc python python-polib python-pillow python-dotenv python-numpy python-pyqt5 python-opengl
|
sudo pacman -S git base-devel gcc python python-polib python-pillow python-dotenv python-numpy python-pyqt5 python-opengl libmad
|
||||||
```
|
```
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
// First thing, try and find a save device, used for settings and what not.
|
||||||
|
{
|
||||||
|
"type": "MODAL",
|
||||||
|
"title": "initial.checking_save.title",
|
||||||
|
"message": "initial.checking_save.message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "WAIT",
|
||||||
|
"seconds": 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SAVE_DEVICE_CHECK",
|
||||||
|
"successMarker": "CONTINUE",
|
||||||
|
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
|
||||||
|
},
|
||||||
|
|
||||||
|
// No save device found
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "SAVE_DEVICE_NOT_FOUND"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_OPTIONS_MARKERS",
|
||||||
|
"title": "initial.no_device.title",
|
||||||
|
"message": "initial.no_device.message",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "initial.no_device.retry",
|
||||||
|
"marker": "SAVE_DEVICE_RETRY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "initial.no_device.continue",
|
||||||
|
"marker": "CONTINUE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save device not found, user to retry, just close modal and loop
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "SAVE_DEVICE_RETRY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "RESTART"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save device found (or continuing without one)
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "CONTINUE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CUTSCENE",
|
||||||
|
"name": "main_menu"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "MAIN_MENU_IDLE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "UI_SHOW",
|
||||||
|
"name": "main_menu"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Options button goes here - no options screen yet, so this is just
|
||||||
|
// an alias landing right back on the idle loop below.
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "OPTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "IDLE"
|
||||||
|
},
|
||||||
|
|
||||||
|
// New Game button goes here.
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "NEW_GAME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL",
|
||||||
|
"title": "main_menu.checking_save.title",
|
||||||
|
"message": "main_menu.checking_save.message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "WAIT",
|
||||||
|
"seconds": 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SAVE_DEVICE_CHECK",
|
||||||
|
"successMarker": "SAVE_DEVICE_FOUND",
|
||||||
|
"failureMarker": "SAVE_DEVICE_NOT_FOUND"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save device not found
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "SAVE_DEVICE_NOT_FOUND"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_OPTIONS_MARKERS",
|
||||||
|
"title": "main_menu.no_device.title",
|
||||||
|
"message": "main_menu.no_device.message",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "main_menu.no_device.retry",
|
||||||
|
"marker": "NEW_GAME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "main_menu.no_device.continue",
|
||||||
|
"marker": "SAVE_DEVICE_FOUND"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save data failed to load (e.g. corrupt/unreadable) - only option is
|
||||||
|
// to retry, no "continue without saving" here since we already know a
|
||||||
|
// device is present.
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "LOAD_ERROR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_OPTIONS_MARKERS",
|
||||||
|
"title": "main_menu.save_load_error.title",
|
||||||
|
"message": "main_menu.save_load_error.message",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "main_menu.save_load_error.retry",
|
||||||
|
"marker": "NEW_GAME"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quit Game button goes here. Cancelling jumps back to
|
||||||
|
// MAIN_MENU_IDLE; confirming falls through to QUIT_GAME, which
|
||||||
|
// ends the process directly.
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "QUIT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_OPTIONS_MARKERS",
|
||||||
|
"title": "main_menu.quit_confirm",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"text": "ui.confirm.confirm",
|
||||||
|
"marker": "QUIT_CONFIRMED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "ui.confirm.cancel",
|
||||||
|
"marker": "MAIN_MENU_IDLE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "QUIT_CONFIRMED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "QUIT_GAME"
|
||||||
|
},
|
||||||
|
|
||||||
|
// main menu just listens for cutscene ending to show the select save modal
|
||||||
|
// so we have that here.
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "SAVE_DEVICE_FOUND"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SAVE_LOAD_ALL_SLOTS",
|
||||||
|
"successMarker": "LOADED",
|
||||||
|
"failureMarker": "LOAD_ERROR"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Must be the very last item - falling off the end here is what
|
||||||
|
// completes the cutscene and fires uiMainMenuOpenSelectSave (see
|
||||||
|
// cutsceneSystemSetOnComplete in ui/screen/mainmenu/uimainmenu.c).
|
||||||
|
// Placing this anywhere earlier would fall through into whatever
|
||||||
|
// comes next instead (e.g. the QUIT block, if placed just before it).
|
||||||
|
{
|
||||||
|
"type": "MARKER",
|
||||||
|
"name": "LOADED"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,11 +5,107 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
|
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
|
||||||
|
|
||||||
|
# Initial Scene
|
||||||
|
msgid "initial.checking_save.title"
|
||||||
|
msgstr "Checking for save data"
|
||||||
|
|
||||||
|
msgid "initial.checking_save.message"
|
||||||
|
msgstr "Please wait..."
|
||||||
|
|
||||||
|
msgid "initial.no_device.title"
|
||||||
|
msgstr "No Save Device Found"
|
||||||
|
|
||||||
|
msgid "initial.no_device.message"
|
||||||
|
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
|
||||||
|
|
||||||
|
msgid "initial.no_device.retry"
|
||||||
|
msgstr "Try again"
|
||||||
|
|
||||||
|
msgid "initial.no_device.continue"
|
||||||
|
msgstr "Continue without saving"
|
||||||
|
|
||||||
|
|
||||||
|
# Main Menu Scene
|
||||||
|
msgid "main_menu.start_game"
|
||||||
|
msgstr "Start Game"
|
||||||
|
|
||||||
|
msgid "main_menu.options"
|
||||||
|
msgstr "Options"
|
||||||
|
|
||||||
|
msgid "main_menu.quit"
|
||||||
|
msgstr "Quit Game"
|
||||||
|
|
||||||
|
msgid "main_menu.quit_confirm"
|
||||||
|
msgstr "Are you sure you want to quit?"
|
||||||
|
|
||||||
|
msgid "main_menu.checking_save.title"
|
||||||
|
msgstr "Checking for save data"
|
||||||
|
|
||||||
|
msgid "main_menu.checking_save.message"
|
||||||
|
msgstr "Please wait..."
|
||||||
|
|
||||||
|
msgid "main_menu.no_device.title"
|
||||||
|
msgstr "No Save Device Found"
|
||||||
|
|
||||||
|
msgid "main_menu.no_device.message"
|
||||||
|
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
|
||||||
|
|
||||||
|
msgid "main_menu.no_device.retry"
|
||||||
|
msgstr "Try again"
|
||||||
|
|
||||||
|
msgid "main_menu.no_device.continue"
|
||||||
|
msgstr "Continue without saving"
|
||||||
|
|
||||||
|
msgid "main_menu.save_load_error.title"
|
||||||
|
msgstr "Error"
|
||||||
|
|
||||||
|
msgid "main_menu.save_load_error.message"
|
||||||
|
msgstr "Failed to load save data. Please try again."
|
||||||
|
|
||||||
|
msgid "main_menu.save_load_error.retry"
|
||||||
|
msgstr "Try Again"
|
||||||
|
|
||||||
|
|
||||||
|
# Select Save Screen
|
||||||
|
msgid "ui.select_save.title"
|
||||||
|
msgstr "Select Save"
|
||||||
|
|
||||||
|
msgid "ui.select_save.empty"
|
||||||
|
msgstr "Empty Slot"
|
||||||
|
|
||||||
|
msgid "ui.select_save.slot_format"
|
||||||
|
msgstr "%s Lv.%d %s"
|
||||||
|
|
||||||
|
msgid "ui.select_save.delete_mode"
|
||||||
|
msgstr "Delete a Save"
|
||||||
|
|
||||||
|
msgid "ui.select_save.delete_confirm"
|
||||||
|
msgstr "Are you sure you want to delete this save?"
|
||||||
|
|
||||||
|
msgid "ui.select_save.name_title"
|
||||||
|
msgstr "Enter game save file name"
|
||||||
|
|
||||||
|
msgid "ui.save_slot.number_format"
|
||||||
|
msgstr "Slot %d"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
#: ui/menu.c:10
|
||||||
msgid "ui.title"
|
msgid "ui.title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Welcome"
|
"Welcome"
|
||||||
|
|
||||||
|
msgid "save.linux.mkdirp_failed"
|
||||||
|
msgstr "Failed to create save directory, check the disk is not full or write-protected."
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
#: src/dusk/ui/frame/settings/uisettings.c
|
||||||
msgid "ui.settings.tabs.general"
|
msgid "ui.settings.tabs.general"
|
||||||
msgstr "General"
|
msgstr "General"
|
||||||
@@ -56,6 +152,114 @@ msgstr "Items"
|
|||||||
msgid "ui.game_menu.settings"
|
msgid "ui.game_menu.settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save"
|
||||||
|
msgstr "Save"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_success"
|
||||||
|
msgstr "Game saved."
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_cancelled"
|
||||||
|
msgstr "Save cancelled."
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_unavailable"
|
||||||
|
msgstr "Can't save - no save device found."
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_temporary"
|
||||||
|
msgstr "This session is temporary - no save device was found, so saving is disabled."
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_create_confirm"
|
||||||
|
msgstr "No save data found. Create a new save?"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_failed_format"
|
||||||
|
msgstr "Save failed: %s"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||||
|
msgid "ui.game_menu.save_check_failed_format"
|
||||||
|
msgstr "Can't save: %s"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||||
|
msgid "ui.initial.no_card.message"
|
||||||
|
msgstr "No save device found. You can continue, but\nprogress will not be saved."
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||||
|
msgid "ui.initial.no_card.retry"
|
||||||
|
msgstr "Retry"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialnocard.c
|
||||||
|
msgid "ui.initial.no_card.continue"
|
||||||
|
msgstr "Continue Anyway"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||||
|
msgid "ui.initial.create_save.message"
|
||||||
|
msgstr "No save data found. Create a new save?"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||||
|
msgid "ui.initial.create_save.yes"
|
||||||
|
msgstr "Yes"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
|
||||||
|
msgid "ui.initial.create_save.no"
|
||||||
|
msgstr "No"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/uiconfirm.c
|
||||||
|
msgid "ui.confirm.confirm"
|
||||||
|
msgstr "Confirm"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/uiconfirm.c
|
||||||
|
msgid "ui.confirm.cancel"
|
||||||
|
msgstr "Cancel"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||||
|
msgid "ui.battle.menu.attack"
|
||||||
|
msgstr "Attack"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||||
|
msgid "ui.battle.menu.flee"
|
||||||
|
msgstr "Flee"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/battle/uibattlemenu.c
|
||||||
|
msgid "ui.battle.menu.target_format"
|
||||||
|
msgstr "Enemy %u (%u/%u HP)"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/battle/uibattlehud.c
|
||||||
|
msgid "ui.battle.hud.hp_format"
|
||||||
|
msgstr "HP %u/%u"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/battle/uibattlehud.c
|
||||||
|
msgid "ui.battle.hud.mp_format"
|
||||||
|
msgstr "MP %u/%u"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/settings/uisettingsaudio.c
|
||||||
|
msgid "ui.settings.audio.placeholder"
|
||||||
|
msgstr "No audio settings yet"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/settings/uisettingsdisplay.c
|
||||||
|
msgid "ui.settings.display.placeholder"
|
||||||
|
msgstr "No display settings yet"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/settings/uisettingsinput.c
|
||||||
|
msgid "ui.settings.input.placeholder"
|
||||||
|
msgstr "No input settings yet"
|
||||||
|
|
||||||
|
#: src/dusk/ui/frame/backpack/uibackpack.c
|
||||||
|
msgid "ui.backpack.category_format"
|
||||||
|
msgstr "Category %u"
|
||||||
|
|
||||||
|
#: src/dusk/ui/overlay/uiloading.c
|
||||||
|
msgid "ui.loading.text"
|
||||||
|
msgstr "loading"
|
||||||
|
|
||||||
|
#: src/dusk/ui/overlay/uiautosave.c
|
||||||
|
msgid "ui.autosave.saving"
|
||||||
|
msgstr "SAVING"
|
||||||
|
|
||||||
msgid "item.potion.name"
|
msgid "item.potion.name"
|
||||||
msgstr "Potion"
|
msgstr "Potion"
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: ExampleApp 1.0\n"
|
|
||||||
"Language: es\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
|
||||||
msgid "ui.title"
|
|
||||||
msgstr ""
|
|
||||||
"Bienvenido"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.general"
|
|
||||||
msgstr "General"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.input"
|
|
||||||
msgstr "Entrada"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.display"
|
|
||||||
msgstr "Pantalla"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.audio"
|
|
||||||
msgstr "Audio"
|
|
||||||
|
|
||||||
msgid "ui.settings.input.deadzone"
|
|
||||||
msgstr "Deadzone"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language"
|
|
||||||
msgstr "Idioma"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language_detail"
|
|
||||||
msgstr "Se aplica después de reiniciar la aplicación."
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.apply"
|
|
||||||
msgstr "Aplicar"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/uiconfirm.c
|
|
||||||
msgid "ui.confirm.discard_changes"
|
|
||||||
msgstr "¿Descartar los cambios no guardados?"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.characters"
|
|
||||||
msgstr "Personajes"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.items"
|
|
||||||
msgstr "Objetos"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.settings"
|
|
||||||
msgstr "Configuración"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potion.name"
|
|
||||||
msgstr "Poción"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potato.name"
|
|
||||||
msgstr "Papa"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.apple.name"
|
|
||||||
msgstr "Manzana"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: ExampleApp 1.0\n"
|
|
||||||
"Language: ja\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=(0);\n"
|
|
||||||
|
|
||||||
#: ui/menu.c:10
|
|
||||||
msgid "ui.title"
|
|
||||||
msgstr ""
|
|
||||||
"歓迎"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.general"
|
|
||||||
msgstr "一般"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.input"
|
|
||||||
msgstr "入力"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.display"
|
|
||||||
msgstr "表示"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.tabs.audio"
|
|
||||||
msgstr "オーディオ"
|
|
||||||
|
|
||||||
msgid "ui.settings.input.deadzone"
|
|
||||||
msgstr "デッドゾーン"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language"
|
|
||||||
msgstr "言語"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
|
||||||
msgid "ui.settings.general.language_detail"
|
|
||||||
msgstr "アプリケーションを再起動すると適用されます。"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/settings/uisettings.c
|
|
||||||
msgid "ui.settings.apply"
|
|
||||||
msgstr "適用"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/uiconfirm.c
|
|
||||||
msgid "ui.confirm.discard_changes"
|
|
||||||
msgstr "未保存の変更を破棄しますか?"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.characters"
|
|
||||||
msgstr "キャラクター"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.items"
|
|
||||||
msgstr "アイテム"
|
|
||||||
|
|
||||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
|
||||||
msgid "ui.game_menu.settings"
|
|
||||||
msgstr "設定"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potion.name"
|
|
||||||
msgstr "ポーション"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.potato.name"
|
|
||||||
msgstr "ジャガイモ"
|
|
||||||
|
|
||||||
#: src/dusk/rpg/item/item.json
|
|
||||||
msgid "item.apple.name"
|
|
||||||
msgstr "リンゴ"
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"meshes":[{"file":"house_4_4.dmf","pos":[2,1,0]},{"file":"house_6_3.dmf","pos":[1,9,0]},{"file":"house_2_2.dmf","pos":[12,6,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"meshes":[{"file":"house_8_4.dmf","pos":[1,1,0]},{"file":"house_3_3.dmf","pos":[11,2,0]},{"file":"house_2_2.dmf","pos":[3,11,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,null,null,null,null,null,null,[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"meshes":[{"file":"house_3_2.dmf","pos":[1,1,0]},{"file":"house_2_3.dmf","pos":[7,2,0]},{"file":"house_4_2.dmf","pos":[1,8,0]},{"file":"house_1_1.dmf","pos":[11,10,0]},{"file":"house_5_2.dmf","pos":[13,6,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"meshes":[{"file":"house_5_5.dmf","pos":[3,2,0]},{"file":"house_2_3.dmf","pos":[10,3,0]},{"file":"house_3_1.dmf","pos":[1,12,0]}],"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],null,null,[1,0],[1,0],[1,0],[1,0],[1,3],[1,3],[1,3],[1,3],[1,3],[1,3],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,1],[4,1],[4,1],[4,1],[4,1],[4,1],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[]}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_-1_0_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_0_0_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_0_1_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_1_0_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_1_1_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"mesh": "meshes/chunks/chunk_2_0_0_0.dmf",
|
|
||||||
"color": [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255
|
|
||||||
],
|
|
||||||
"texture": "tiles.png"
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
# NOTE: libmad itself is GPL-licensed (see https://www.underbit.com/products/mad/),
|
||||||
|
# not MIT like the rest of this project. Any binary linking it must comply
|
||||||
|
# with the GPL - a deliberate tradeoff accepted for MP3 decode correctness
|
||||||
|
# (see audiostreammp3decodersw.c's own comment on why minimp3 wasn't enough).
|
||||||
|
|
||||||
|
find_path(LIBMAD_INCLUDE_DIR NAMES mad.h)
|
||||||
|
mark_as_advanced(LIBMAD_INCLUDE_DIR)
|
||||||
|
|
||||||
|
find_library(LIBMAD_LIBRARY NAMES mad)
|
||||||
|
mark_as_advanced(LIBMAD_LIBRARY)
|
||||||
|
|
||||||
|
include(FindPackageHandleStandardArgs)
|
||||||
|
find_package_handle_standard_args(
|
||||||
|
libmad
|
||||||
|
REQUIRED_VARS
|
||||||
|
LIBMAD_LIBRARY
|
||||||
|
LIBMAD_INCLUDE_DIR
|
||||||
|
)
|
||||||
|
|
||||||
|
if(libmad_FOUND AND NOT TARGET libmad::mad)
|
||||||
|
add_library(libmad::mad UNKNOWN IMPORTED)
|
||||||
|
set_target_properties(libmad::mad
|
||||||
|
PROPERTIES
|
||||||
|
INTERFACE_INCLUDE_DIRECTORIES ${LIBMAD_INCLUDE_DIR}
|
||||||
|
IMPORTED_LOCATION ${LIBMAD_LIBRARY}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
@@ -61,6 +61,8 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
|||||||
zstd
|
zstd
|
||||||
z
|
z
|
||||||
lzma
|
lzma
|
||||||
|
asnd
|
||||||
|
mad
|
||||||
)
|
)
|
||||||
|
|
||||||
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
|
if(DUSK_DOLPHIN_BUILD_TYPE STREQUAL "ISO")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ target_link_libraries(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
|||||||
pspgu
|
pspgu
|
||||||
pspaudio
|
pspaudio
|
||||||
pspaudiolib
|
pspaudiolib
|
||||||
|
pspmp3
|
||||||
psputility
|
psputility
|
||||||
pspvfpu
|
pspvfpu
|
||||||
pspvram
|
pspvram
|
||||||
@@ -59,12 +60,42 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
|||||||
DUSK_DISPLAY_OVERSCAN=6
|
DUSK_DISPLAY_OVERSCAN=6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# logDebug()/logError() additionally write every call to a file on the
|
||||||
|
# Memory Stick (ms0:/PSP/GAME/Dusk/*.log) - a real, measured source of
|
||||||
|
# per-call hitching (fopen+write+fclose every log line), not just a
|
||||||
|
# theoretical cost. Default off so a normal build doesn't pay it; opt in
|
||||||
|
# with -DDUSK_PSP_LOG_FILE=ON when the log file itself is actually needed
|
||||||
|
# (e.g. no way to see stdout on real hardware).
|
||||||
|
set(DUSK_PSP_LOG_FILE OFF CACHE BOOL "Also write PSP debug/error logs to ms0:/PSP/GAME/Dusk/*.log (slow - Memory Stick I/O per call).")
|
||||||
|
if(DUSK_PSP_LOG_FILE)
|
||||||
|
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_PSP_LOG_FILE
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||||
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||||
DUSK_ASSERTIONS_FAKED
|
DUSK_ASSERTIONS_FAKED
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Generate PARAM.SFO as a normal tracked build output (instead of letting
|
||||||
|
# create_pbp_file() auto-generate + delete it) so it can be reused below by
|
||||||
|
# a properly dependency-tracked EBOOT.PBP repack step.
|
||||||
|
set(DUSK_PSP_PARAM_SFO "${DUSK_BUILD_DIR}/PARAM.SFO")
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT "${DUSK_PSP_PARAM_SFO}"
|
||||||
|
COMMAND "$ENV{PSPDEV}/bin/mksfoex" "-d" "MEMSIZE=1" "-s" "APP_VER=01.00"
|
||||||
|
"${DUSK_BINARY_TARGET_NAME}" "${DUSK_PSP_PARAM_SFO}"
|
||||||
|
COMMENT "Generating PARAM.SFO for ${DUSK_BINARY_TARGET_NAME}"
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(DuskPspParamSfo DEPENDS "${DUSK_PSP_PARAM_SFO}")
|
||||||
|
|
||||||
|
# create_pbp_file()'s own POST_BUILD chain (below) also consumes
|
||||||
|
# DUSK_PSP_PARAM_SFO, so make sure it exists before that chain runs.
|
||||||
|
add_dependencies(${DUSK_BINARY_TARGET_NAME} DuskPspParamSfo)
|
||||||
|
|
||||||
# Postbuild, create .pbp file for PSP.
|
# Postbuild, create .pbp file for PSP.
|
||||||
create_pbp_file(
|
create_pbp_file(
|
||||||
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
||||||
@@ -74,4 +105,33 @@ create_pbp_file(
|
|||||||
TITLE "${DUSK_BINARY_TARGET_NAME}"
|
TITLE "${DUSK_BINARY_TARGET_NAME}"
|
||||||
PSAR_PATH ${DUSK_ASSETS_ZIP}
|
PSAR_PATH ${DUSK_ASSETS_ZIP}
|
||||||
VERSION 01.00
|
VERSION 01.00
|
||||||
|
SFO_PATH "${DUSK_PSP_PARAM_SFO}"
|
||||||
|
OUTPUT_DIR "${DUSK_BUILD_DIR}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CreatePBP.cmake's pack-pbp step is a POST_BUILD command tied to the
|
||||||
|
# executable target, so it only reruns when the ELF itself relinks. That
|
||||||
|
# means regenerating dusk.dsk (assets) alone, without touching any C
|
||||||
|
# source, silently leaves EBOOT.PBP embedding a stale asset pak. Repack it
|
||||||
|
# here as a normal file-tracked custom command depending on both the
|
||||||
|
# executable and the asset zip, so EBOOT.PBP always reflects the current
|
||||||
|
# assets even when nothing else about the build changed.
|
||||||
|
set(DUSK_PSP_EBOOT "${DUSK_BUILD_DIR}/EBOOT.PBP")
|
||||||
|
if(BUILD_PRX)
|
||||||
|
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>.prx")
|
||||||
|
else()
|
||||||
|
set(DUSK_PSP_EXECUTABLE "$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>")
|
||||||
|
endif()
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT "${DUSK_PSP_EBOOT}"
|
||||||
|
COMMAND "$ENV{PSPDEV}/bin/pack-pbp" "${DUSK_PSP_EBOOT}" "${DUSK_PSP_PARAM_SFO}"
|
||||||
|
"NULL" "NULL" "NULL" "NULL" "NULL"
|
||||||
|
"${DUSK_PSP_EXECUTABLE}" "${DUSK_ASSETS_ZIP}"
|
||||||
|
DEPENDS
|
||||||
|
"$<TARGET_FILE:${DUSK_BINARY_TARGET_NAME}>"
|
||||||
|
"${DUSK_PSP_PARAM_SFO}"
|
||||||
|
"${DUSK_ASSETS_ZIP}"
|
||||||
|
COMMENT "Repacking EBOOT.PBP (tracks executable + asset pak freshness)"
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(DuskPspEbootRepack ALL DEPENDS "${DUSK_PSP_EBOOT}")
|
||||||
@@ -4,6 +4,17 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
|||||||
DUSK_WII
|
DUSK_WII
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Wii save storage method - see src/duskdolphin/save/savedeviceplatform.h.
|
||||||
|
set(DUSK_SAVE_WII_METHOD "SD" CACHE STRING
|
||||||
|
"Wii save storage: NAND (internal storage via ISFS), CARD (GameCube-\
|
||||||
|
compatible memory card emulation), or SD (SD card via libfat)"
|
||||||
|
)
|
||||||
|
set_property(CACHE DUSK_SAVE_WII_METHOD PROPERTY STRINGS "NAND" "CARD" "SD")
|
||||||
|
|
||||||
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_SAVE_WII_METHOD_${DUSK_SAVE_WII_METHOD}
|
||||||
|
)
|
||||||
|
|
||||||
# Generate Homebrew Channel meta.xml from project identity variables
|
# Generate Homebrew Channel meta.xml from project identity variables
|
||||||
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
|
string(TIMESTAMP DUSK_BUILD_DATE "%Y%m%d000000" UTC)
|
||||||
configure_file(
|
configure_file(
|
||||||
|
|||||||
@@ -345,12 +345,14 @@
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAP_NAME = "overworld";
|
||||||
|
|
||||||
function chunkPaths(x, y, z) {
|
function chunkPaths(x, y, z) {
|
||||||
return {
|
return {
|
||||||
raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`,
|
raw: `assetsraw/maps/${MAP_NAME}/chunks/chunk_${x}_${y}_${z}.json`,
|
||||||
dcf: `assets/chunks/${x}_${y}_${z}.dcf`,
|
dcf: `assets/maps/${MAP_NAME}/chunks/${x}_${y}_${z}.dcf`,
|
||||||
terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
|
terrainDmf: `assets/maps/${MAP_NAME}/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
|
||||||
terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`,
|
terrainModel: `assets/maps/${MAP_NAME}/models/chunks/chunk_${x}_${y}_${z}_0.json`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +769,7 @@
|
|||||||
async function buildChunkBrowseList() {
|
async function buildChunkBrowseList() {
|
||||||
const list = document.getElementById("chunkBrowseList");
|
const list = document.getElementById("chunkBrowseList");
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
const res = await apiLs("assetsraw/chunks");
|
const res = await apiLs(`assetsraw/maps/${MAP_NAME}/chunks`);
|
||||||
for(const entry of res.entries || []) {
|
for(const entry of res.entries || []) {
|
||||||
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
|
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
|
||||||
if(!m) continue;
|
if(!m) continue;
|
||||||
|
|||||||
@@ -209,7 +209,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": "invalid chunk coordinates"}, 400)
|
self.send_json({"error": "invalid chunk coordinates"}, 400)
|
||||||
return
|
return
|
||||||
|
|
||||||
json_path = PROJECT_ROOT / "assetsraw" / "chunks" / f"chunk_{x}_{y}_{z}.json"
|
json_path = (
|
||||||
|
PROJECT_ROOT / "assetsraw" / "maps" / "overworld" / "chunks"
|
||||||
|
/ f"chunk_{x}_{y}_{z}.json"
|
||||||
|
)
|
||||||
if not json_path.is_file():
|
if not json_path.is_file():
|
||||||
self.send_json({"error": "chunk json not found"}, 404)
|
self.send_json({"error": "chunk json not found"}, 404)
|
||||||
return
|
return
|
||||||
|
|||||||
+10
-1
@@ -5,10 +5,15 @@
|
|||||||
|
|
||||||
add_subdirectory(dusk)
|
add_subdirectory(dusk)
|
||||||
|
|
||||||
|
if(DUSK_NETWORK)
|
||||||
|
add_subdirectory(dusknetwork)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
|
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
|
||||||
add_subdirectory(dusklinux)
|
add_subdirectory(dusklinux)
|
||||||
add_subdirectory(dusksdl2)
|
add_subdirectory(dusksdl2)
|
||||||
add_subdirectory(duskgl)
|
add_subdirectory(duskgl)
|
||||||
|
add_subdirectory(duskmad)
|
||||||
|
|
||||||
elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
|
elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
|
||||||
add_subdirectory(duskpsp)
|
add_subdirectory(duskpsp)
|
||||||
@@ -20,7 +25,11 @@ elseif(DUSK_TARGET_SYSTEM STREQUAL "vita")
|
|||||||
add_subdirectory(dusksdl2)
|
add_subdirectory(dusksdl2)
|
||||||
add_subdirectory(duskgl)
|
add_subdirectory(duskgl)
|
||||||
|
|
||||||
elseif(DUSK_TARGET_SYSTEM STREQUAL "wii" OR DUSK_TARGET_SYSTEM STREQUAL "gamecube")
|
elseif(
|
||||||
|
DUSK_TARGET_SYSTEM STREQUAL "wii" OR
|
||||||
|
DUSK_TARGET_SYSTEM STREQUAL "gamecube"
|
||||||
|
)
|
||||||
add_subdirectory(duskdolphin)
|
add_subdirectory(duskdolphin)
|
||||||
|
add_subdirectory(duskmad)
|
||||||
|
|
||||||
endif()
|
endif()
|
||||||
+15
-4
@@ -14,12 +14,21 @@ if(NOT libzip_FOUND)
|
|||||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip)
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC zip)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# assetdsk.c calls crc32() directly (to verify dusk.dsk archive checksums).
|
||||||
|
# find_package(libzip) above already resolves ZLIB as a side effect, so
|
||||||
|
# ZLIB_FOUND may already be true without ZLIB::ZLIB having been linked to
|
||||||
|
# our target - link it unconditionally rather than guarding on ZLIB_FOUND.
|
||||||
|
find_package(ZLIB REQUIRED)
|
||||||
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC ZLIB::ZLIB)
|
||||||
|
|
||||||
if(NOT stb_image_FOUND)
|
if(NOT stb_image_FOUND)
|
||||||
find_package(stb REQUIRED)
|
find_package(stb REQUIRED)
|
||||||
if(STB_IMAGE_FOUND)
|
if(STB_IMAGE_FOUND)
|
||||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC stb_image)
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC stb_image)
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "stb_image not found. Please ensure stb is correctly fetched.")
|
message(FATAL_ERROR
|
||||||
|
"stb_image not found. Please ensure stb is correctly fetched."
|
||||||
|
)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -28,7 +37,9 @@ if(NOT yyjson_FOUND)
|
|||||||
if(yyjson_FOUND)
|
if(yyjson_FOUND)
|
||||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC yyjson::yyjson)
|
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC yyjson::yyjson)
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "yyjson not found. Please ensure yyjson is correctly fetched.")
|
message(FATAL_ERROR
|
||||||
|
"yyjson not found. Please ensure yyjson is correctly fetched."
|
||||||
|
)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -53,9 +64,9 @@ target_sources(${DUSK_BINARY_TARGET_NAME}
|
|||||||
|
|
||||||
# Subdirs
|
# Subdirs
|
||||||
add_subdirectory(animation)
|
add_subdirectory(animation)
|
||||||
add_subdirectory(event)
|
|
||||||
add_subdirectory(assert)
|
add_subdirectory(assert)
|
||||||
add_subdirectory(asset)
|
add_subdirectory(asset)
|
||||||
|
add_subdirectory(audio)
|
||||||
add_subdirectory(console)
|
add_subdirectory(console)
|
||||||
add_subdirectory(display)
|
add_subdirectory(display)
|
||||||
add_subdirectory(log)
|
add_subdirectory(log)
|
||||||
@@ -63,12 +74,12 @@ add_subdirectory(engine)
|
|||||||
add_subdirectory(error)
|
add_subdirectory(error)
|
||||||
add_subdirectory(input)
|
add_subdirectory(input)
|
||||||
add_subdirectory(locale)
|
add_subdirectory(locale)
|
||||||
|
add_subdirectory(perf)
|
||||||
add_subdirectory(rpg)
|
add_subdirectory(rpg)
|
||||||
add_subdirectory(scene)
|
add_subdirectory(scene)
|
||||||
add_subdirectory(system)
|
add_subdirectory(system)
|
||||||
add_subdirectory(time)
|
add_subdirectory(time)
|
||||||
add_subdirectory(ui)
|
add_subdirectory(ui)
|
||||||
add_subdirectory(network)
|
|
||||||
add_subdirectory(save)
|
add_subdirectory(save)
|
||||||
add_subdirectory(util)
|
add_subdirectory(util)
|
||||||
add_subdirectory(thread)
|
add_subdirectory(thread)
|
||||||
@@ -7,4 +7,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|||||||
PUBLIC
|
PUBLIC
|
||||||
easing.c
|
easing.c
|
||||||
animation.c
|
animation.c
|
||||||
|
keyframe.c
|
||||||
)
|
)
|
||||||
|
|||||||
+102
-22
@@ -11,42 +11,122 @@
|
|||||||
void animationInit(
|
void animationInit(
|
||||||
animation_t *anim,
|
animation_t *anim,
|
||||||
keyframe_t *keyframes,
|
keyframe_t *keyframes,
|
||||||
uint16_t keyframeCount
|
uint16_t *keyframeCounts,
|
||||||
|
const uint16_t layerCount
|
||||||
) {
|
) {
|
||||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
assertNotNull(keyframeCounts, "Keyframe counts pointer cannot be null.");
|
||||||
|
assertTrue(layerCount > 0, "Layer count must be greater than zero.");
|
||||||
|
|
||||||
|
memoryZero(anim, sizeof(animation_t));
|
||||||
anim->keyframes = keyframes;
|
anim->keyframes = keyframes;
|
||||||
anim->keyframeCount = keyframeCount;
|
anim->keyframeCounts = keyframeCounts;
|
||||||
|
anim->layerCount = layerCount;
|
||||||
|
|
||||||
|
// Determine duration
|
||||||
|
float_t duration = 0.0f;
|
||||||
|
for(uint16_t layer = 0; layer < layerCount; layer++) {
|
||||||
|
uint16_t keyframeCount = keyframeCounts[layer];
|
||||||
|
assertTrue(keyframeCount > 0, "Keyframe count invalid.");
|
||||||
|
keyframe_t *layerKeyframes = keyframes + layer * keyframeCount;
|
||||||
|
|
||||||
|
#ifdef DUSK_ASSERTIONS
|
||||||
|
// Check that the keyframes are sorted by time.
|
||||||
|
for(uint16_t i = 1; i < keyframeCount; i++) {
|
||||||
|
assertTrue(
|
||||||
|
layerKeyframes[i].time >= layerKeyframes[i - 1].time,
|
||||||
|
"Keyframes must be sorted by time."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
keyframe_t *lastKeyframe = layerKeyframes + keyframeCount - 1;
|
||||||
|
duration = mathMax(duration, lastKeyframe->time);
|
||||||
|
}
|
||||||
|
assertTrue(duration > 0, "Animation duration must be greater than 0.");
|
||||||
|
anim->duration = duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
float_t animationGetValue(animation_t *anim, const float_t time) {
|
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer) {
|
||||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||||
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null.");
|
assertTrue(layer < anim->layerCount, "Layer index out of bounds.");
|
||||||
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
|
|
||||||
assertTrue(time >= 0, "Time must be non-negative.");
|
|
||||||
|
|
||||||
keyframe_t *start;
|
uint16_t keyframeCount = anim->keyframeCounts[layer];
|
||||||
keyframe_t *end;
|
keyframe_t *layerKeyframes = anim->keyframes + layer * keyframeCount;
|
||||||
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1;
|
return keyframeGetValue(layerKeyframes, keyframeCount, anim->time);
|
||||||
keyframe_t *current = anim->keyframes;
|
}
|
||||||
start = current;
|
|
||||||
|
|
||||||
do {
|
void animationUpdate(
|
||||||
if(current->time > time) {
|
animation_t *anim,
|
||||||
end = current;
|
const float_t deltaTime
|
||||||
|
) {
|
||||||
|
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||||
|
assertTrue(deltaTime >= 0, "Delta time must be non-negative.");
|
||||||
|
|
||||||
|
bool_t justCompleted = false;
|
||||||
|
|
||||||
|
if(!(anim->flags & ANIMATION_FLAG_INTERNAL_COMPLETED)) {
|
||||||
|
bool_t loop = (anim->flags & ANIMATION_FLAG_LOOP) != 0;
|
||||||
|
bool_t pingpong = (anim->flags & ANIMATION_FLAG_PINGPONG) != 0;
|
||||||
|
assertFalse(
|
||||||
|
loop && pingpong,
|
||||||
|
"Cannot set both ANIMATION_FLAG_LOOP and ANIMATION_FLAG_PINGPONG."
|
||||||
|
);
|
||||||
|
|
||||||
|
bool_t backward;
|
||||||
|
if(pingpong) {
|
||||||
|
backward = (anim->flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0;
|
||||||
|
} else {
|
||||||
|
backward = (anim->flags & ANIMATION_FLAG_REVERSE) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve boundary crossings one at a time, so a single large deltaTime
|
||||||
|
// can correctly loop/pingpong across multiple boundaries in one call.
|
||||||
|
float_t remaining = deltaTime;
|
||||||
|
while(remaining > 0.0f) {
|
||||||
|
float_t toBoundary = (
|
||||||
|
backward ? anim->time : (anim->duration - anim->time)
|
||||||
|
);
|
||||||
|
if(remaining < toBoundary) {
|
||||||
|
anim->time += backward ? -remaining : remaining;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
start = current;
|
|
||||||
current++;
|
|
||||||
|
|
||||||
if(current > last) {
|
remaining -= toBoundary;
|
||||||
end = start;
|
anim->time = backward ? 0.0f : anim->duration;
|
||||||
|
|
||||||
|
bool_t stopHere;
|
||||||
|
if(backward) {
|
||||||
|
stopHere = (anim->flags & ANIMATION_FLAG_STOP_BEGINNING) != 0;
|
||||||
|
} else {
|
||||||
|
stopHere = (anim->flags & ANIMATION_FLAG_STOP_END) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(stopHere) {
|
||||||
|
justCompleted = true;
|
||||||
|
break;
|
||||||
|
} else if(pingpong) {
|
||||||
|
backward = !backward;
|
||||||
|
if(backward) anim->flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
||||||
|
else anim->flags &= ~ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
||||||
|
} else if(loop) {
|
||||||
|
anim->time = backward ? anim->duration : 0.0f;
|
||||||
|
if(anim->onLoop) anim->onLoop(anim->user);
|
||||||
|
} else {
|
||||||
|
justCompleted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} while(true);
|
}
|
||||||
|
|
||||||
float_t t = (time - start->time) / (end->time - start->time);
|
if(justCompleted) anim->flags |= ANIMATION_FLAG_INTERNAL_COMPLETED;
|
||||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
}
|
||||||
|
|
||||||
|
// Call onUpdate for each layer.
|
||||||
|
for(uint16_t layer = 0; layer < anim->layerCount; layer++) {
|
||||||
|
float_t value = animationGetLayerValue(anim, layer);
|
||||||
|
if(anim->onUpdate) anim->onUpdate(layer, value, anim->user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(justCompleted && anim->onComplete) anim->onComplete(anim->user);
|
||||||
}
|
}
|
||||||
@@ -6,29 +6,89 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "keyframe.h"
|
#include "keyframe.h"
|
||||||
|
|
||||||
|
#define ANIMATION_FLAG_LOOP (1 << 0)
|
||||||
|
#define ANIMATION_FLAG_REVERSE (1 << 1)
|
||||||
|
#define ANIMATION_FLAG_PINGPONG (1 << 2)
|
||||||
|
#define ANIMATION_FLAG_STOP_BEGINNING (1 << 3)
|
||||||
|
#define ANIMATION_FLAG_STOP_END (1 << 4)
|
||||||
|
|
||||||
|
// Internal - tracks which direction a pingponging animation is currently
|
||||||
|
// travelling. Do not set this manually, it is managed by animationUpdate().
|
||||||
|
#define ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD (1 << 7)
|
||||||
|
|
||||||
|
// Internal - set once the animation has stopped advancing (see
|
||||||
|
// animationUpdate()). Do not set this manually. There is currently no way to
|
||||||
|
// restart a completed animation short of clearing this bit and resetting
|
||||||
|
// anim->time by hand.
|
||||||
|
#define ANIMATION_FLAG_INTERNAL_COMPLETED (1 << 6)
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
keyframe_t *keyframes;
|
keyframe_t *keyframes;
|
||||||
uint16_t keyframeCount;
|
uint16_t *keyframeCounts;
|
||||||
|
uint16_t layerCount;
|
||||||
|
float_t time;
|
||||||
|
float_t duration;
|
||||||
|
uint8_t flags;
|
||||||
|
|
||||||
|
void *user;
|
||||||
|
void (*onUpdate)(const uint16_t layer, const float_t value, void *user);
|
||||||
|
void (*onComplete)(void *user);
|
||||||
|
void (*onLoop)(void *user);
|
||||||
} animation_t;
|
} animation_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes an animation.
|
* Initializes an animation with the given keyframes and layer count.
|
||||||
*
|
*
|
||||||
* @param anim The animation to initialize.
|
* @param anim Pointer to the animation to initialize.
|
||||||
* @param keyframes The keyframes to use for the animation.
|
* @param keyframes Pointer to the array of keyframes for each layer.
|
||||||
* @param keyframeCount The number of keyframes in the animation.
|
* @param keyframeCount Number of keyframes in each layer.
|
||||||
|
* @param layerCount Number of layers in the animation.
|
||||||
*/
|
*/
|
||||||
void animationInit(
|
void animationInit(
|
||||||
animation_t *anim,
|
animation_t *anim,
|
||||||
keyframe_t *keyframes,
|
keyframe_t *keyframes,
|
||||||
uint16_t keyframeCount
|
uint16_t *keyframeCounts,
|
||||||
|
const uint16_t layerCount
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the value of the animation at a given time.
|
* Sets the current time of the animation, clamping it to the valid range.
|
||||||
|
* This will call the onUpdate callback but none of the other callbacks.
|
||||||
*
|
*
|
||||||
* @param anim The animation to get the value from.
|
* @param anim Pointer to the animation to set the time for.
|
||||||
* @param time The time at which to get the value, in seconds.
|
* @param time The new time to set for the animation.
|
||||||
* @return The value of the animation at the given time.
|
|
||||||
*/
|
*/
|
||||||
float_t animationGetValue(animation_t *anim, const float_t time);
|
void animationSetTime(animation_t *anim, const float_t time);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current value of a specific layer in the animation based on the
|
||||||
|
* current animation time.
|
||||||
|
*/
|
||||||
|
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the animation state based on the elapsed time. Advances anim->time
|
||||||
|
* by deltaTime (or against it, if ANIMATION_FLAG_REVERSE is set), then
|
||||||
|
* resolves whatever happens when it reaches the 0 or duration boundary:
|
||||||
|
*
|
||||||
|
* - ANIMATION_FLAG_PINGPONG: reflects off the boundary and continues playing
|
||||||
|
* in the opposite direction, forever, unless stopped (see below).
|
||||||
|
* - ANIMATION_FLAG_LOOP: wraps back around to the other boundary and keeps
|
||||||
|
* playing in the same direction, forever, unless stopped (see below).
|
||||||
|
* - ANIMATION_FLAG_STOP_BEGINNING / ANIMATION_FLAG_STOP_END: when the
|
||||||
|
* animation reaches that specific boundary, it clamps there and stops
|
||||||
|
* (firing onComplete) instead of looping/pingponging past it.
|
||||||
|
* - If none of the above apply at a boundary, the animation clamps there and
|
||||||
|
* stops, firing onComplete.
|
||||||
|
*
|
||||||
|
* onUpdate is called for every layer on every call. onLoop is called each
|
||||||
|
* time a loop wraps around. onComplete is called at most once, the moment
|
||||||
|
* the animation stops advancing.
|
||||||
|
*
|
||||||
|
* @param anim Pointer to the animation to update.
|
||||||
|
* @param deltaTime Time elapsed since the last update (in seconds).
|
||||||
|
*/
|
||||||
|
void animationUpdate(
|
||||||
|
animation_t *anim,
|
||||||
|
const float_t deltaTime
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "keyframe.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
|
||||||
|
float_t keyframeGetValue(
|
||||||
|
const keyframe_t *keyframes,
|
||||||
|
const uint32_t keyframeCount,
|
||||||
|
const float_t time
|
||||||
|
) {
|
||||||
|
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||||
|
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
||||||
|
assertTrue(time >= 0, "Time must be non-negative.");
|
||||||
|
#ifdef DUSK_ASSERTIONS
|
||||||
|
// Checks that the keyframes are sorted by time.
|
||||||
|
for(uint32_t i = 1; i < keyframeCount; i++) {
|
||||||
|
assertTrue(
|
||||||
|
keyframes[i].time >= keyframes[i - 1].time,
|
||||||
|
"Keyframes must be sorted by time."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
|
||||||
|
if(time >= last->time) return last->value;
|
||||||
|
|
||||||
|
// Since time < last->time (checked above), current is guaranteed to stop
|
||||||
|
// at or before reaching last, so no separate end-of-array check is needed.
|
||||||
|
keyframe_t *current = (keyframe_t *)keyframes;
|
||||||
|
keyframe_t *start = current;
|
||||||
|
while(current->time <= time) {
|
||||||
|
start = current;
|
||||||
|
current++;
|
||||||
|
}
|
||||||
|
keyframe_t *end = current;
|
||||||
|
|
||||||
|
float_t t = (time - start->time) / (end->time - start->time);
|
||||||
|
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
||||||
|
}
|
||||||
@@ -11,3 +11,17 @@ typedef struct {
|
|||||||
float_t value;
|
float_t value;
|
||||||
easingtype_t easing;
|
easingtype_t easing;
|
||||||
} keyframe_t;
|
} keyframe_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of a keyframe at a given time.
|
||||||
|
*
|
||||||
|
* @param keyframes The keyframes to get the value from.
|
||||||
|
* @param keyframeCount The number of keyframes in the array.
|
||||||
|
* @param time The time at which to get the value, in seconds.
|
||||||
|
* @return The value of the keyframe at the given time.
|
||||||
|
*/
|
||||||
|
float_t keyframeGetValue(
|
||||||
|
const keyframe_t *keyframes,
|
||||||
|
const uint32_t keyframeCount,
|
||||||
|
const float_t time
|
||||||
|
);
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef DUSK_ASSERTIONS_FAKED
|
#ifndef DUSK_ASSERTIONS_FAKED
|
||||||
|
#define DUSK_ASSERTIONS 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the assert system. Must be the very first call in engine
|
* Initializes the assert system. Must be the very first call in engine
|
||||||
* startup.
|
* startup.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
PUBLIC
|
PUBLIC
|
||||||
asset.c
|
asset.c
|
||||||
assetbatch.c
|
assetdsk.c
|
||||||
assetfile.c
|
assetfile.c
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -19,6 +19,8 @@ asset_t ASSET;
|
|||||||
errorret_t assetInit(void) {
|
errorret_t assetInit(void) {
|
||||||
memoryZero(&ASSET, sizeof(asset_t));
|
memoryZero(&ASSET, sizeof(asset_t));
|
||||||
|
|
||||||
|
threadMutexInit(&ASSET.zipLock);
|
||||||
|
|
||||||
for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) {
|
for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) {
|
||||||
threadMutexInit(&ASSET.loading[i].mutex);
|
threadMutexInit(&ASSET.loading[i].mutex);
|
||||||
}
|
}
|
||||||
@@ -35,9 +37,10 @@ errorret_t assetInit(void) {
|
|||||||
bool_t assetFileExists(const char_t *filename) {
|
bool_t assetFileExists(const char_t *filename) {
|
||||||
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
|
assertStrLenMax(filename, ASSET_FILE_NAME_MAX, "Filename too long.");
|
||||||
|
|
||||||
zip_int64_t idx = zip_name_locate(ASSET.zip, filename, 0);
|
threadMutexLock(&ASSET.zipLock);
|
||||||
if(idx < 0) return false;
|
bool_t found = zip_name_locate(ASSET.zip, filename, 0) >= 0;
|
||||||
return true;
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
assetentry_t * assetGetEntry(
|
assetentry_t * assetGetEntry(
|
||||||
@@ -117,6 +120,15 @@ errorret_t assetRequireLoaded(assetentry_t *entry) {
|
|||||||
assetEntryLock(entry);
|
assetEntryLock(entry);
|
||||||
|
|
||||||
while(entry->state != ASSET_ENTRY_STATE_LOADED) {
|
while(entry->state != ASSET_ENTRY_STATE_LOADED) {
|
||||||
|
// A failed load transitions to ERROR, not LOADED - without this check
|
||||||
|
// this loop spins forever on any load failure (assetUpdate() itself
|
||||||
|
// still returns OK, since a single asset failing isn't meant to halt
|
||||||
|
// the whole update loop - see its ASSET_ENTRY_STATE_ERROR handling).
|
||||||
|
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
|
||||||
|
assetEntryUnlock(entry);
|
||||||
|
errorThrow("Failed to load asset: %s", entry->name);
|
||||||
|
}
|
||||||
|
|
||||||
usleep(1000);
|
usleep(1000);
|
||||||
errorret_t ret = assetUpdate();
|
errorret_t ret = assetUpdate();
|
||||||
if(errorIsNotOk(ret)) {
|
if(errorIsNotOk(ret)) {
|
||||||
@@ -322,7 +334,9 @@ errorret_t assetUpdate(void) {
|
|||||||
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
|
} else if(loading->entry->state == ASSET_ENTRY_STATE_LOADED) {
|
||||||
assetentry_t *loadedEntry = loading->entry;
|
assetentry_t *loadedEntry = loading->entry;
|
||||||
loading->entry = NULL;
|
loading->entry = NULL;
|
||||||
eventInvoke(&loadedEntry->onLoaded, loadedEntry);
|
if(loadedEntry->onLoaded) {
|
||||||
|
loadedEntry->onLoaded(loadedEntry, loadedEntry->onLoadedUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loading++;
|
loading++;
|
||||||
@@ -346,8 +360,10 @@ errorret_t assetUpdate(void) {
|
|||||||
assetentry_t *errEntry = loading->entry;
|
assetentry_t *errEntry = loading->entry;
|
||||||
loading->entry = NULL;
|
loading->entry = NULL;
|
||||||
threadMutexUnlock(&loading->mutex);
|
threadMutexUnlock(&loading->mutex);
|
||||||
eventInvoke(&errEntry->onError, errEntry);
|
if(errEntry->onError) {
|
||||||
errorThrow("Failed to load asset asynchronously.");
|
errEntry->onError(errEntry, errEntry->onErrorUser);
|
||||||
|
}
|
||||||
|
loading++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,5 +450,6 @@ errorret_t assetDispose(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errorChain(assetDisposePlatform());
|
errorChain(assetDisposePlatform());
|
||||||
|
threadMutexDispose(&ASSET.zipLock);
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
@@ -26,10 +26,40 @@
|
|||||||
#define ASSET_LOADING_COUNT_MAX 10
|
#define ASSET_LOADING_COUNT_MAX 10
|
||||||
#define ASSET_ENTRY_COUNT_MAX 64
|
#define ASSET_ENTRY_COUNT_MAX 64
|
||||||
|
|
||||||
|
#define ASSET_BASE_DIRECTORY_MAX 256
|
||||||
|
|
||||||
typedef struct asset_s {
|
typedef struct asset_s {
|
||||||
|
// The dusk.dsk archive - a single uncompressed (ZIP_STORED) zip, so
|
||||||
|
// every entry gets the reliable repeated seeking/re-opening that
|
||||||
|
// libzip only supports reliably for uncompressed entries (locale
|
||||||
|
// strings, MP3 loop restarts, etc.).
|
||||||
zip_t *zip;
|
zip_t *zip;
|
||||||
|
|
||||||
|
// Directory dusk.dsk was actually found/opened in (on PSP, the directory
|
||||||
|
// EBOOT.PBP lives in, since dusk.dsk is embedded inside it rather than a
|
||||||
|
// loose file) - populated by each platform's own assetInitXxx() once it
|
||||||
|
// locates the file. Save devices that persist to a real filesystem path
|
||||||
|
// (Linux, PSP, Wii SD) use this as their base directory instead of a
|
||||||
|
// separate hardcoded location, so saves live next to wherever the game
|
||||||
|
// itself was actually launched from rather than some unrelated fixed
|
||||||
|
// path. Not meaningful for devices with no filesystem-path concept at
|
||||||
|
// all (GameCube/Wii memory card, Wii NAND's ISFS namespace).
|
||||||
|
char_t baseDirectory[ASSET_BASE_DIRECTORY_MAX];
|
||||||
|
|
||||||
assetplatform_t platform;
|
assetplatform_t platform;
|
||||||
|
|
||||||
|
// Guards every libzip call against ASSET.zip (zip_fopen,
|
||||||
|
// zip_fread, zip_fclose, zip_fseek, zip_stat, zip_name_locate - see
|
||||||
|
// assetfile.c/assetFileExists). libzip is documented as not thread-safe,
|
||||||
|
// and this asset system genuinely calls it from multiple real threads at
|
||||||
|
// once (main thread, the background load thread below, and - on
|
||||||
|
// platforms that stream PCM from an asset on their own thread, like PSP
|
||||||
|
// - an audio feeder thread too). Confirmed necessary on real PSP
|
||||||
|
// hardware: without this lock, concurrent zip_fopen/zip_fread calls from
|
||||||
|
// different threads corrupted reads (EINVAL, then zlib data errors) once
|
||||||
|
// dusk.dsk stopped being read entirely into memory up front.
|
||||||
|
threadmutex_t zipLock;
|
||||||
|
|
||||||
// Background loading thread.
|
// Background loading thread.
|
||||||
thread_t loadThread;
|
thread_t loadThread;
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "assetbatch.h"
|
|
||||||
#include "asset.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
void assetBatchInit(
|
|
||||||
assetbatch_t *batch,
|
|
||||||
const uint16_t count,
|
|
||||||
const assetbatchdesc_t *descs
|
|
||||||
) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
assertNotNull(descs, "Descs cannot be NULL.");
|
|
||||||
assertTrue(count > 0, "Count must be greater than 0.");
|
|
||||||
assertTrue(
|
|
||||||
count <= ASSET_BATCH_COUNT_MAX, "Count exceeds ASSET_BATCH_COUNT_MAX."
|
|
||||||
);
|
|
||||||
|
|
||||||
memoryZero(batch, sizeof(assetbatch_t));
|
|
||||||
batch->count = count;
|
|
||||||
|
|
||||||
eventInit(
|
|
||||||
&batch->onLoaded,
|
|
||||||
batch->onLoadedCallbacks, batch->onLoadedUsers, ASSET_BATCH_EVENT_MAX
|
|
||||||
);
|
|
||||||
eventInit(
|
|
||||||
&batch->onEntryLoaded,
|
|
||||||
batch->onEntryLoadedCallbacks,
|
|
||||||
batch->onEntryLoadedUsers,
|
|
||||||
ASSET_BATCH_EVENT_MAX
|
|
||||||
);
|
|
||||||
eventInit(
|
|
||||||
&batch->onError,
|
|
||||||
batch->onErrorCallbacks, batch->onErrorUsers, ASSET_BATCH_EVENT_MAX
|
|
||||||
);
|
|
||||||
eventInit(
|
|
||||||
&batch->onEntryError,
|
|
||||||
batch->onEntryErrorCallbacks,
|
|
||||||
batch->onEntryErrorUsers,
|
|
||||||
ASSET_BATCH_EVENT_MAX
|
|
||||||
);
|
|
||||||
|
|
||||||
for(uint16_t i = 0; i < count; i++) {
|
|
||||||
batch->inputs[i] = descs[i].input;
|
|
||||||
batch->entries[i] = assetLock(
|
|
||||||
descs[i].path, descs[i].type, &batch->inputs[i]
|
|
||||||
);
|
|
||||||
|
|
||||||
if(batch->entries[i]->state == ASSET_ENTRY_STATE_LOADED) {
|
|
||||||
// Already loaded (cached) - count it now, no subscription needed.
|
|
||||||
batch->loadedCount++;
|
|
||||||
} else if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) {
|
|
||||||
batch->errorCount++;
|
|
||||||
} else {
|
|
||||||
eventSubscribe(
|
|
||||||
&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb, batch
|
|
||||||
);
|
|
||||||
eventSubscribe(
|
|
||||||
&batch->entries[i]->onError, assetBatchEntryOnErrorCb, batch
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetBatchLock(assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
assetEntryLock(batch->entries[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetBatchUnlock(assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
assetEntryUnlock(batch->entries[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t assetBatchIsLoaded(const assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
if(batch->entries[i]->state != ASSET_ENTRY_STATE_LOADED) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t assetBatchHasError(const assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
if(batch->entries[i]->state == ASSET_ENTRY_STATE_ERROR) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetBatchRequireLoaded(assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
|
|
||||||
bool_t allDone;
|
|
||||||
do {
|
|
||||||
allDone = true;
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
const assetentrystate_t state = batch->entries[i]->state;
|
|
||||||
if(state == ASSET_ENTRY_STATE_ERROR) {
|
|
||||||
errorThrow("Asset '%s' failed to load.", batch->entries[i]->name);
|
|
||||||
}
|
|
||||||
if(state != ASSET_ENTRY_STATE_LOADED) {
|
|
||||||
allDone = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(!allDone) {
|
|
||||||
usleep(1000);
|
|
||||||
errorChain(assetUpdate());
|
|
||||||
}
|
|
||||||
} while(!allDone);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetBatchDispose(assetbatch_t *batch) {
|
|
||||||
assertNotNull(batch, "Batch cannot be NULL.");
|
|
||||||
for(uint16_t i = 0; i < batch->count; i++) {
|
|
||||||
if(batch->entries[i]) {
|
|
||||||
// Unsubscribe while we still hold a lock so the entry is live.
|
|
||||||
eventUnsubscribe(&batch->entries[i]->onLoaded, assetBatchEntryOnLoadedCb);
|
|
||||||
eventUnsubscribe(&batch->entries[i]->onError, assetBatchEntryOnErrorCb);
|
|
||||||
assetUnlockEntry(batch->entries[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
memoryZero(batch, sizeof(assetbatch_t));
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetBatchEntryOnLoadedCb(void *params, void *user) {
|
|
||||||
assetentry_t *entry = (assetentry_t *)params;
|
|
||||||
assetbatch_t *batch = (assetbatch_t *)user;
|
|
||||||
|
|
||||||
batch->loadedCount++;
|
|
||||||
eventInvoke(&batch->onEntryLoaded, entry);
|
|
||||||
|
|
||||||
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
|
|
||||||
if(batch->errorCount == 0) {
|
|
||||||
eventInvoke(&batch->onLoaded, batch);
|
|
||||||
} else {
|
|
||||||
eventInvoke(&batch->onError, batch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetBatchEntryOnErrorCb(void *params, void *user) {
|
|
||||||
assetentry_t *entry = (assetentry_t *)params;
|
|
||||||
assetbatch_t *batch = (assetbatch_t *)user;
|
|
||||||
|
|
||||||
batch->errorCount++;
|
|
||||||
eventInvoke(&batch->onEntryError, entry);
|
|
||||||
|
|
||||||
if((uint16_t)(batch->loadedCount + batch->errorCount) >= batch->count) {
|
|
||||||
eventInvoke(&batch->onError, batch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "asset/loader/assetentry.h"
|
|
||||||
#include "asset/loader/assetloader.h"
|
|
||||||
#include "event/event.h"
|
|
||||||
|
|
||||||
#define ASSET_BATCH_COUNT_MAX 64
|
|
||||||
#define ASSET_BATCH_EVENT_MAX 4
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char_t *path;
|
|
||||||
assetloadertype_t type;
|
|
||||||
assetloaderinput_t input;
|
|
||||||
} assetbatchdesc_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
assetentry_t *entries[ASSET_BATCH_COUNT_MAX];
|
|
||||||
assetloaderinput_t inputs[ASSET_BATCH_COUNT_MAX];
|
|
||||||
uint16_t count;
|
|
||||||
uint16_t loadedCount;
|
|
||||||
uint16_t errorCount;
|
|
||||||
|
|
||||||
/** Fires once when every entry loaded. params = assetbatch_t * */
|
|
||||||
event_t onLoaded;
|
|
||||||
eventcallback_t onLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
|
|
||||||
void *onLoadedUsers[ASSET_BATCH_EVENT_MAX];
|
|
||||||
|
|
||||||
/** Fires each time a single entry loads. params = assetentry_t * */
|
|
||||||
event_t onEntryLoaded;
|
|
||||||
eventcallback_t onEntryLoadedCallbacks[ASSET_BATCH_EVENT_MAX];
|
|
||||||
void *onEntryLoadedUsers[ASSET_BATCH_EVENT_MAX];
|
|
||||||
|
|
||||||
/** Fires when all entries finish (any with errors). params: assetbatch_t * */
|
|
||||||
event_t onError;
|
|
||||||
eventcallback_t onErrorCallbacks[ASSET_BATCH_EVENT_MAX];
|
|
||||||
void *onErrorUsers[ASSET_BATCH_EVENT_MAX];
|
|
||||||
|
|
||||||
/** Fires each time a single entry errors. params = assetentry_t * */
|
|
||||||
event_t onEntryError;
|
|
||||||
eventcallback_t onEntryErrorCallbacks[ASSET_BATCH_EVENT_MAX];
|
|
||||||
void *onEntryErrorUsers[ASSET_BATCH_EVENT_MAX];
|
|
||||||
} assetbatch_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialises the batch from an array of descriptors. Each entry is locked
|
|
||||||
* and queued for loading immediately.
|
|
||||||
*
|
|
||||||
* @param batch Batch to initialise.
|
|
||||||
* @param descs Array of entry descriptors (need not outlive this call).
|
|
||||||
* @param count Number of descriptors (must be <= ASSET_BATCH_COUNT_MAX).
|
|
||||||
*/
|
|
||||||
void assetBatchInit(
|
|
||||||
assetbatch_t *batch,
|
|
||||||
uint16_t count,
|
|
||||||
const assetbatchdesc_t *descs
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquires one additional lock on every entry in the batch.
|
|
||||||
*
|
|
||||||
* @param batch Batch to lock.
|
|
||||||
*/
|
|
||||||
void assetBatchLock(assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Releases one lock from every entry in the batch. When an entry's lock
|
|
||||||
* count reaches zero it will be reaped on the next assetUpdate.
|
|
||||||
*
|
|
||||||
* @param batch Batch to unlock.
|
|
||||||
*/
|
|
||||||
void assetBatchUnlock(assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if every entry in the batch has finished loading.
|
|
||||||
*
|
|
||||||
* @param batch Batch to query.
|
|
||||||
*/
|
|
||||||
bool_t assetBatchIsLoaded(const assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if any entry in the batch is in an error state.
|
|
||||||
*
|
|
||||||
* @param batch Batch to query.
|
|
||||||
*/
|
|
||||||
bool_t assetBatchHasError(const assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blocks until every entry is loaded. Returns an error if any entry fails.
|
|
||||||
*
|
|
||||||
* @param batch Batch to wait on.
|
|
||||||
*/
|
|
||||||
errorret_t assetBatchRequireLoaded(assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Releases the batch's lock on every entry and clears the batch. After this
|
|
||||||
* call the batch struct may be reused with assetBatchInit.
|
|
||||||
*
|
|
||||||
* @param batch Batch to dispose.
|
|
||||||
*/
|
|
||||||
void assetBatchDispose(assetbatch_t *batch);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event trampoline invoked when a batch entry finishes loading.
|
|
||||||
* Increments the loaded counter and fires batch-level events.
|
|
||||||
*
|
|
||||||
* @param params The loaded assetentry_t pointer.
|
|
||||||
* @param user The owning assetbatch_t pointer.
|
|
||||||
*/
|
|
||||||
void assetBatchEntryOnLoadedCb(void *params, void *user);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event trampoline invoked when a batch entry fails to load.
|
|
||||||
* Increments the error counter and fires batch-level events.
|
|
||||||
*
|
|
||||||
* @param params The errored assetentry_t pointer.
|
|
||||||
* @param user The owning assetbatch_t pointer.
|
|
||||||
*/
|
|
||||||
void assetBatchEntryOnErrorCb(void *params, void *user);
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "assetdsk.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/endian.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include <zlib.h>
|
||||||
|
|
||||||
|
errorret_t assetDskParseHeader(
|
||||||
|
const uint8_t *bytes,
|
||||||
|
const size_t bytesSize,
|
||||||
|
assetdskheader_t *outHeader
|
||||||
|
) {
|
||||||
|
assertNotNull(bytes, "Bytes cannot be NULL.");
|
||||||
|
assertNotNull(outHeader, "Out header cannot be NULL.");
|
||||||
|
|
||||||
|
if(bytesSize < ASSET_DSK_HEADER_SIZE) {
|
||||||
|
errorThrow("dusk.dsk header is truncated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(memoryCompare(bytes, ASSET_DSK_MAGIC, ASSET_DSK_MAGIC_SIZE) != 0) {
|
||||||
|
errorThrow("dusk.dsk has an invalid magic header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every field is a little-endian uint32_t regardless of host - convert
|
||||||
|
// to host order (a no-op on little-endian hosts, a real byteswap on
|
||||||
|
// Dolphin's big-endian PowerPC).
|
||||||
|
uint32_t fields[3];
|
||||||
|
memoryCopy(fields, bytes + ASSET_DSK_MAGIC_SIZE, sizeof(fields));
|
||||||
|
for(uint8_t i = 0; i < 3; i++) {
|
||||||
|
fields[i] = endianLittleToHost32(fields[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t version = fields[0];
|
||||||
|
if(version != ASSET_DSK_VERSION) {
|
||||||
|
errorThrow("dusk.dsk has an unsupported version: %u", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
outHeader->size = fields[1];
|
||||||
|
outHeader->checksum = fields[2];
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetDskOpenFromPath(
|
||||||
|
const char_t *path,
|
||||||
|
zip_t **outZip
|
||||||
|
) {
|
||||||
|
errorChain(assetDskOpenFromPathRange(path, 0, SIZE_MAX, outZip));
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetDskOpenFromPathRange(
|
||||||
|
const char_t *path,
|
||||||
|
const size_t baseOffset,
|
||||||
|
const size_t baseSize,
|
||||||
|
zip_t **outZip
|
||||||
|
) {
|
||||||
|
assertNotNull(path, "Path cannot be NULL.");
|
||||||
|
assertNotNull(outZip, "Out zip cannot be NULL.");
|
||||||
|
|
||||||
|
*outZip = NULL;
|
||||||
|
|
||||||
|
FILE *headerFile = fopen(path, "rb");
|
||||||
|
if(headerFile == NULL) {
|
||||||
|
errorThrow("Failed to open dusk.dsk: %s", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(fseek(headerFile, (long) baseOffset, SEEK_SET) != 0) {
|
||||||
|
fclose(headerFile);
|
||||||
|
errorThrow("Failed to seek to dusk.dsk range in file: %s", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t headerBytes[ASSET_DSK_HEADER_SIZE];
|
||||||
|
size_t headerRead = fread(headerBytes, 1, sizeof(headerBytes), headerFile);
|
||||||
|
fclose(headerFile);
|
||||||
|
if(headerRead != sizeof(headerBytes)) {
|
||||||
|
errorThrow("Failed to read dusk.dsk header: %s", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetdskheader_t header;
|
||||||
|
errorChain(assetDskParseHeader(headerBytes, sizeof(headerBytes), &header));
|
||||||
|
|
||||||
|
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > baseSize) {
|
||||||
|
errorThrow("dusk.dsk header describes a range beyond its containing file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_error_t zipError;
|
||||||
|
zip_error_init(&zipError);
|
||||||
|
|
||||||
|
zip_source_t *source = zip_source_file_create(
|
||||||
|
path,
|
||||||
|
(zip_uint64_t) (baseOffset + ASSET_DSK_HEADER_SIZE),
|
||||||
|
(zip_int64_t) header.size,
|
||||||
|
&zipError
|
||||||
|
);
|
||||||
|
if(source == NULL) {
|
||||||
|
errorThrow(
|
||||||
|
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
|
||||||
|
if(*outZip == NULL) {
|
||||||
|
zip_source_free(source);
|
||||||
|
errorThrow(
|
||||||
|
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetDskOpenFromBuffer(
|
||||||
|
uint8_t *buffer,
|
||||||
|
const size_t bufferSize,
|
||||||
|
zip_t **outZip
|
||||||
|
) {
|
||||||
|
assertNotNull(buffer, "Buffer cannot be NULL.");
|
||||||
|
assertNotNull(outZip, "Out zip cannot be NULL.");
|
||||||
|
|
||||||
|
*outZip = NULL;
|
||||||
|
|
||||||
|
assetdskheader_t header;
|
||||||
|
errorChain(assetDskParseHeader(buffer, bufferSize, &header));
|
||||||
|
|
||||||
|
if((size_t) ASSET_DSK_HEADER_SIZE + header.size > bufferSize) {
|
||||||
|
errorThrow("dusk.dsk header describes a range beyond the buffer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t *data = buffer + ASSET_DSK_HEADER_SIZE;
|
||||||
|
uint32_t checksum = (uint32_t) crc32(0L, data, (uInt) header.size);
|
||||||
|
if(checksum != header.checksum) {
|
||||||
|
errorThrow("dusk.dsk archive failed checksum verification.");
|
||||||
|
}
|
||||||
|
|
||||||
|
zip_error_t zipError;
|
||||||
|
zip_error_init(&zipError);
|
||||||
|
|
||||||
|
// freep=0 - this is a non-owning window into the same caller-owned
|
||||||
|
// buffer, not an independent allocation libzip should free.
|
||||||
|
zip_source_t *source = zip_source_buffer_create(
|
||||||
|
data, header.size, 0, &zipError
|
||||||
|
);
|
||||||
|
if(source == NULL) {
|
||||||
|
errorThrow(
|
||||||
|
"Failed to create dusk.dsk source: %s", zip_error_strerror(&zipError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
*outZip = zip_open_from_source(source, ZIP_RDONLY, &zipError);
|
||||||
|
if(*outZip == NULL) {
|
||||||
|
zip_source_free(source);
|
||||||
|
errorThrow(
|
||||||
|
"Failed to open dusk.dsk archive: %s", zip_error_strerror(&zipError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "error/error.h"
|
||||||
|
#include <zip.h>
|
||||||
|
|
||||||
|
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
|
||||||
|
// never gets misread as a valid dusk.dsk.
|
||||||
|
#define ASSET_DSK_MAGIC_SIZE 4
|
||||||
|
#define ASSET_DSK_MAGIC "DSK2"
|
||||||
|
#define ASSET_DSK_VERSION 2
|
||||||
|
|
||||||
|
// magic(4) + version(4) + size(4) + checksum(4), all little-endian
|
||||||
|
// regardless of host - see assetDskParseHeader. The zip archive itself
|
||||||
|
// starts immediately after, at ASSET_DSK_HEADER_SIZE.
|
||||||
|
#define ASSET_DSK_HEADER_SIZE 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
|
||||||
|
* (ZIP_STORED) zip archive (see tools/asset/pack) rather than DEFLATEd -
|
||||||
|
* the bulk of what's packed (audio, images) is already compressed at the
|
||||||
|
* file level, so DEFLATE-ing the whole archive on top buys negligible
|
||||||
|
* space for real CPU cost, and every entry gets the reliable repeated
|
||||||
|
* seeking/re-opening that libzip only supports for uncompressed entries
|
||||||
|
* (locale line-by-line reads, MP3 loop restarts, etc.) instead of just a
|
||||||
|
* hand-picked subset of files.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
uint32_t size;
|
||||||
|
uint32_t checksum;
|
||||||
|
} assetdskheader_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a DSK2 header from a raw byte buffer (at least
|
||||||
|
* ASSET_DSK_HEADER_SIZE bytes), validating the magic/version and
|
||||||
|
* byte-swapping the little-endian fields to host order.
|
||||||
|
*
|
||||||
|
* @param bytes Buffer containing the header (and beyond).
|
||||||
|
* @param bytesSize Number of bytes available at `bytes`.
|
||||||
|
* @param outHeader Filled with the parsed header on success.
|
||||||
|
* @return OK on success, error if too short, bad magic, or unsupported version.
|
||||||
|
*/
|
||||||
|
errorret_t assetDskParseHeader(
|
||||||
|
const uint8_t *bytes,
|
||||||
|
const size_t bytesSize,
|
||||||
|
assetdskheader_t *outHeader
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dusk.dsk archive given a filesystem path, using a lazy/
|
||||||
|
* windowed file-backed zip source - no more memory used than the
|
||||||
|
* existing per-platform small read buffers, matching the memory
|
||||||
|
* characteristics of a plain zip_open() on the whole file. The checksum
|
||||||
|
* is intentionally not verified here since doing so would require
|
||||||
|
* reading the whole (potentially large) archive just to compute it.
|
||||||
|
*
|
||||||
|
* See assetDskOpenFromPathRange() for the case where the DSK2 blob isn't
|
||||||
|
* the whole file (e.g. embedded inside another container).
|
||||||
|
*
|
||||||
|
* @param path Filesystem path to the dusk.dsk file.
|
||||||
|
* @param outZip Set to the opened archive on success.
|
||||||
|
* @return OK on success, error if the file is missing, too short, has a
|
||||||
|
* bad header, or the archive fails to open.
|
||||||
|
*/
|
||||||
|
errorret_t assetDskOpenFromPath(
|
||||||
|
const char_t *path,
|
||||||
|
zip_t **outZip
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as assetDskOpenFromPath(), but the DSK2 blob doesn't start at the
|
||||||
|
* beginning of `path` - it's a byte range embedded inside a larger
|
||||||
|
* container file (e.g. the PSAR region of an EBOOT.PBP). Every offset the
|
||||||
|
* header describes is relative to `baseOffset`; `baseSize` bounds them
|
||||||
|
* (the size of the embedded blob, not the whole container file) - pass
|
||||||
|
* SIZE_MAX to skip that check when the caller doesn't know/care.
|
||||||
|
*
|
||||||
|
* @param path Filesystem path to the container file.
|
||||||
|
* @param baseOffset Byte offset within `path` where the DSK2 blob starts.
|
||||||
|
* @param baseSize Number of bytes available at `baseOffset`.
|
||||||
|
* @param outZip Set to the opened archive on success.
|
||||||
|
* @return OK on success, error if the file is missing, too short, has a
|
||||||
|
* bad header, or the archive fails to open.
|
||||||
|
*/
|
||||||
|
errorret_t assetDskOpenFromPathRange(
|
||||||
|
const char_t *path,
|
||||||
|
const size_t baseOffset,
|
||||||
|
const size_t baseSize,
|
||||||
|
zip_t **outZip
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dusk.dsk archive already fully resident in memory (e.g. a
|
||||||
|
* PSAR embedded in an EBOOT.PBP, or an ISO-embedded file already read via
|
||||||
|
* DVD_ReadAbs - platforms that already buffer the whole file for reasons
|
||||||
|
* unrelated to this format). The returned archive doesn't take ownership
|
||||||
|
* of `buffer` (it's opened as a non-owning sub-range of it) - the caller
|
||||||
|
* remains responsible for freeing it, and must keep it alive for as long
|
||||||
|
* as the archive stays open. Verifies the checksum, since the bytes are
|
||||||
|
* already resident.
|
||||||
|
*
|
||||||
|
* @param buffer The whole dusk.dsk file's bytes.
|
||||||
|
* @param bufferSize Number of bytes at `buffer`.
|
||||||
|
* @param outZip Set to the opened archive on success.
|
||||||
|
* @return OK on success, error if too short, has a bad header/checksum, or
|
||||||
|
* the archive fails to open.
|
||||||
|
*/
|
||||||
|
errorret_t assetDskOpenFromBuffer(
|
||||||
|
uint8_t *buffer,
|
||||||
|
const size_t bufferSize,
|
||||||
|
zip_t **outZip
|
||||||
|
);
|
||||||
+69
-13
@@ -24,9 +24,11 @@ errorret_t assetFileInit(
|
|||||||
file->params = params;
|
file->params = params;
|
||||||
file->output = output;
|
file->output = output;
|
||||||
|
|
||||||
// Stat the file
|
|
||||||
zip_stat_init(&file->stat);
|
zip_stat_init(&file->stat);
|
||||||
if(!zip_stat(ASSET.zip, filename, 0, &file->stat) == 0) {
|
threadMutexLock(&ASSET.zipLock);
|
||||||
|
int statResult = zip_stat(ASSET.zip, filename, 0, &file->stat);
|
||||||
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
|
if(statResult != 0) {
|
||||||
errorThrow("Failed to stat asset file: %s", filename);
|
errorThrow("Failed to stat asset file: %s", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,25 @@ errorret_t assetFileRewind(assetfile_t *file) {
|
|||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer seeking within the still-open handle over closing and
|
||||||
|
// re-opening it. Repeatedly closing/re-opening the same compressed zip
|
||||||
|
// entry (once per rewind - e.g. once per locale string lookup) was
|
||||||
|
// confirmed unreliable on at least one platform's zip backend, silently
|
||||||
|
// skipping ranges of the decompressed content on some re-opens after
|
||||||
|
// the first. A real seek avoids that failure mode entirely, with no
|
||||||
|
// extra memory cost over the close+reopen fallback.
|
||||||
|
threadMutexLock(&ASSET.zipLock);
|
||||||
|
bool_t seekable = zip_file_is_seekable(file->zipFile);
|
||||||
|
int seekResult = seekable ? zip_fseek(file->zipFile, 0, SEEK_SET) : -1;
|
||||||
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
|
if(seekable) {
|
||||||
|
if(seekResult != 0) {
|
||||||
|
errorThrow("Failed to seek asset file: %s", file->filename);
|
||||||
|
}
|
||||||
|
file->position = 0;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
errorChain(assetFileClose(file));
|
errorChain(assetFileClose(file));
|
||||||
errorChain(assetFileOpen(file));
|
errorChain(assetFileOpen(file));
|
||||||
errorOk();
|
errorOk();
|
||||||
@@ -55,10 +76,11 @@ errorret_t assetFileRewind(assetfile_t *file) {
|
|||||||
errorret_t assetFileOpen(assetfile_t *file) {
|
errorret_t assetFileOpen(assetfile_t *file) {
|
||||||
assertNotNull(file, "Asset file cannot be NULL.");
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
assertNotNull(file->filename, "Asset file filename cannot be NULL.");
|
assertNotNull(file->filename, "Asset file filename cannot be NULL.");
|
||||||
assertNotNull(ASSET.zip, "Asset zip cannot be NULL.");
|
|
||||||
assertNull(file->zipFile, "Asset file already open.");
|
assertNull(file->zipFile, "Asset file already open.");
|
||||||
|
|
||||||
|
threadMutexLock(&ASSET.zipLock);
|
||||||
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
|
file->zipFile = zip_fopen(ASSET.zip, file->filename, 0);
|
||||||
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
if(file->zipFile == NULL) {
|
if(file->zipFile == NULL) {
|
||||||
errorThrow("Failed to open asset file: %s", file->filename);
|
errorThrow("Failed to open asset file: %s", file->filename);
|
||||||
}
|
}
|
||||||
@@ -79,21 +101,52 @@ errorret_t assetFileRead(
|
|||||||
uint8_t tempBuffer[256];
|
uint8_t tempBuffer[256];
|
||||||
while(bytesRemaining > 0) {
|
while(bytesRemaining > 0) {
|
||||||
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
size_t chunkSize = mathMin(bytesRemaining, sizeof(tempBuffer));
|
||||||
|
// The recursive call below already advances file->position by
|
||||||
|
// chunkSize (the non-NULL branch does this itself) - do not also
|
||||||
|
// advance it here, or every skip ends up double-counted.
|
||||||
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
errorChain(assetFileRead(file, tempBuffer, chunkSize));
|
||||||
file->position += chunkSize;
|
|
||||||
bytesRemaining -= chunkSize;
|
bytesRemaining -= chunkSize;
|
||||||
}
|
}
|
||||||
file->lastRead = bufferSize;
|
file->lastRead = bufferSize;
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
// I assume zip_fread takes buffer NULL for skipping?
|
// Some zip_fread() implementations (seen on PSP) reject a single call
|
||||||
zip_int64_t bytesRead = zip_fread(file->zipFile, buffer, bufferSize);
|
// asking for the entire (potentially large) file at once with EINVAL;
|
||||||
|
// the line reader above only ever asks for up to 1024 bytes per call and
|
||||||
|
// works fine, so read in bounded chunks here too.
|
||||||
|
size_t totalRead = 0;
|
||||||
|
uint8_t *dest = (uint8_t *)buffer;
|
||||||
|
while(totalRead < bufferSize) {
|
||||||
|
size_t chunkSize = mathMin(
|
||||||
|
bufferSize - totalRead, ASSET_FILE_READ_CHUNK_MAX
|
||||||
|
);
|
||||||
|
threadMutexLock(&ASSET.zipLock);
|
||||||
|
zip_int64_t bytesRead = zip_fread(
|
||||||
|
file->zipFile, dest + totalRead, chunkSize
|
||||||
|
);
|
||||||
|
errorret_t readError = errorOkImpl();
|
||||||
if(bytesRead < 0) {
|
if(bytesRead < 0) {
|
||||||
errorThrow("Failed to read from asset file: %s", file->filename);
|
// Built (not thrown) while still holding the lock, so
|
||||||
|
// zip_file_strerror() reads the just-failed zipFile's error state
|
||||||
|
// before another thread can touch it - errorThrow() itself isn't
|
||||||
|
// used here since it returns immediately, which would leave
|
||||||
|
// ASSET.zipLock held forever.
|
||||||
|
readError = errorThrowImpl(
|
||||||
|
&ERROR_STATE, ERROR_NOT_OK, __FILE__, __func__, __LINE__,
|
||||||
|
"Failed to read from asset file: %s (%s)",
|
||||||
|
file->filename, zip_file_strerror(file->zipFile)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
file->position += bytesRead;
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
file->lastRead = bytesRead;
|
if(errorIsNotOk(readError)) {
|
||||||
|
errorChain(readError);
|
||||||
|
}
|
||||||
|
if(bytesRead == 0) break;
|
||||||
|
totalRead += (size_t)bytesRead;
|
||||||
|
}
|
||||||
|
file->position += totalRead;
|
||||||
|
file->lastRead = totalRead;
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +154,10 @@ errorret_t assetFileClose(assetfile_t *file) {
|
|||||||
assertNotNull(file, "Asset file cannot be NULL.");
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
assertNotNull(file->zipFile, "Asset file must be opened before closing.");
|
assertNotNull(file->zipFile, "Asset file must be opened before closing.");
|
||||||
|
|
||||||
if(zip_fclose(file->zipFile) != 0) {
|
threadMutexLock(&ASSET.zipLock);
|
||||||
|
int closeResult = zip_fclose(file->zipFile);
|
||||||
|
threadMutexUnlock(&ASSET.zipLock);
|
||||||
|
if(closeResult != 0) {
|
||||||
errorThrow("Failed to close asset file: %s", file->filename);
|
errorThrow("Failed to close asset file: %s", file->filename);
|
||||||
}
|
}
|
||||||
file->zipFile = NULL;
|
file->zipFile = NULL;
|
||||||
@@ -201,7 +257,7 @@ const uint8_t *assetFileLineReaderUnreadPtr(
|
|||||||
return reader->readBuffer + reader->bufferStart;
|
return reader->readBuffer + reader->bufferStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
static errorret_t assetFileLineReaderAppend(
|
errorret_t assetFileLineReaderAppend(
|
||||||
assetfilelinereader_t *reader,
|
assetfilelinereader_t *reader,
|
||||||
const uint8_t *src,
|
const uint8_t *src,
|
||||||
size_t srcLength
|
size_t srcLength
|
||||||
@@ -223,7 +279,7 @@ static errorret_t assetFileLineReaderAppend(
|
|||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
||||||
assertNotNull(reader, "Reader cannot be NULL.");
|
assertNotNull(reader, "Reader cannot be NULL.");
|
||||||
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
|
assertNotNull(reader->outBuffer, "Out buffer cannot be NULL.");
|
||||||
assertTrue(
|
assertTrue(
|
||||||
@@ -233,7 +289,7 @@ static void assetFileLineReaderTerminate(assetfilelinereader_t *reader) {
|
|||||||
reader->outBuffer[reader->lineLength] = '\0';
|
reader->outBuffer[reader->lineLength] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
static ssize_t assetFileLineReaderFindNewline(
|
ssize_t assetFileLineReaderFindNewline(
|
||||||
const assetfilelinereader_t *reader
|
const assetfilelinereader_t *reader
|
||||||
) {
|
) {
|
||||||
size_t i;
|
size_t i;
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
#include "error/error.h"
|
#include "error/error.h"
|
||||||
#include <zip.h>
|
#include <zip.h>
|
||||||
|
|
||||||
#define ASSET_FILE_NAME_MAX 48
|
#define ASSET_FILE_NAME_MAX 128
|
||||||
|
|
||||||
|
// Max bytes requested per zip_fread() call in assetFileRead(). Some
|
||||||
|
// zip_fread() implementations (seen on PSP) reject a single call asking
|
||||||
|
// for very large amounts of data at once; the locale line reader has
|
||||||
|
// always used 1024-byte reads successfully, so that's the proven-safe cap.
|
||||||
|
#define ASSET_FILE_READ_CHUNK_MAX 1024
|
||||||
|
|
||||||
typedef struct assetfile_s assetfile_t;
|
typedef struct assetfile_s assetfile_t;
|
||||||
|
|
||||||
@@ -144,6 +150,63 @@ void assetFileLineReaderInit(
|
|||||||
const size_t outBufferSize
|
const size_t outBufferSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of bytes still unread in the line reader's read buffer.
|
||||||
|
*
|
||||||
|
* @param reader The line reader to check.
|
||||||
|
* @return Number of unread bytes remaining in the read buffer.
|
||||||
|
*/
|
||||||
|
size_t assetFileLineReaderUnreadBytes(const assetfilelinereader_t *reader);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a pointer to the first unread byte in the line reader's read
|
||||||
|
* buffer.
|
||||||
|
*
|
||||||
|
* @param reader The line reader to check.
|
||||||
|
* @return Pointer to the first unread byte.
|
||||||
|
*/
|
||||||
|
const uint8_t *assetFileLineReaderUnreadPtr(
|
||||||
|
const assetfilelinereader_t *reader
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends src to the line reader's current line buffer, growing lineLength.
|
||||||
|
*
|
||||||
|
* @param reader The line reader whose out buffer to append to.
|
||||||
|
* @param src Bytes to append.
|
||||||
|
* @param srcLength Number of bytes in src.
|
||||||
|
* @return Error state if any (the line would exceed the output buffer).
|
||||||
|
*/
|
||||||
|
errorret_t assetFileLineReaderAppend(
|
||||||
|
assetfilelinereader_t *reader,
|
||||||
|
const uint8_t *src,
|
||||||
|
size_t srcLength
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null-terminates the line reader's current line buffer at lineLength.
|
||||||
|
*
|
||||||
|
* @param reader The line reader whose out buffer to terminate.
|
||||||
|
*/
|
||||||
|
void assetFileLineReaderTerminate(assetfilelinereader_t *reader);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches the line reader's unread buffered bytes for a newline character.
|
||||||
|
*
|
||||||
|
* @param reader The line reader to search.
|
||||||
|
* @return The index of the newline within readBuffer, or -1 if not found.
|
||||||
|
*/
|
||||||
|
ssize_t assetFileLineReaderFindNewline(const assetfilelinereader_t *reader);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refills the line reader's read buffer from the underlying file once its
|
||||||
|
* buffered bytes are fully consumed. A no-op once the file is at EOF.
|
||||||
|
*
|
||||||
|
* @param reader The line reader to refill.
|
||||||
|
* @return Error state if any.
|
||||||
|
*/
|
||||||
|
errorret_t assetFileLineReaderFill(assetfilelinereader_t *reader);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the next line from the asset file into the line buffer. The line
|
* Reads the next line from the asset file into the line buffer. The line
|
||||||
* buffer is null-terminated and does not include the newline character.
|
* buffer is null-terminated and does not include the newline character.
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ add_subdirectory(locale)
|
|||||||
add_subdirectory(json)
|
add_subdirectory(json)
|
||||||
add_subdirectory(chunk)
|
add_subdirectory(chunk)
|
||||||
add_subdirectory(dmf)
|
add_subdirectory(dmf)
|
||||||
|
add_subdirectory(cutscene)
|
||||||
|
add_subdirectory(wav)
|
||||||
|
add_subdirectory(mp3)
|
||||||
@@ -35,22 +35,6 @@ void assetEntryInit(
|
|||||||
entry->input = NULL;
|
entry->input = NULL;
|
||||||
}
|
}
|
||||||
refInit(&entry->refs, entry, NULL, NULL, NULL);
|
refInit(&entry->refs, entry, NULL, NULL, NULL);
|
||||||
|
|
||||||
eventInit(
|
|
||||||
&entry->onLoaded,
|
|
||||||
entry->onLoadedCallbacks, entry->onLoadedUsers,
|
|
||||||
ASSET_ENTRY_EVENT_MAX
|
|
||||||
);
|
|
||||||
eventInit(
|
|
||||||
&entry->onUnloaded,
|
|
||||||
entry->onUnloadedCallbacks, entry->onUnloadedUsers,
|
|
||||||
ASSET_ENTRY_EVENT_MAX
|
|
||||||
);
|
|
||||||
eventInit(
|
|
||||||
&entry->onError,
|
|
||||||
entry->onErrorCallbacks, entry->onErrorUsers,
|
|
||||||
ASSET_ENTRY_EVENT_MAX
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void assetEntryLock(assetentry_t *entry) {
|
void assetEntryLock(assetentry_t *entry) {
|
||||||
@@ -97,7 +81,7 @@ errorret_t assetEntryDispose(assetentry_t *entry) {
|
|||||||
"Asset entry still refed at dispose time."
|
"Asset entry still refed at dispose time."
|
||||||
);
|
);
|
||||||
|
|
||||||
eventInvoke(&entry->onUnloaded, entry);
|
if(entry->onUnloaded) entry->onUnloaded(entry, entry->onUnloadedUser);
|
||||||
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
|
errorChain(ASSET_LOADER_CALLBACKS[entry->type].dispose(entry));
|
||||||
memoryZero(entry, sizeof(assetentry_t));
|
memoryZero(entry, sizeof(assetentry_t));
|
||||||
errorOk();
|
errorOk();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "asset/loader/assetloading.h"
|
#include "asset/loader/assetloading.h"
|
||||||
#include "event/event.h"
|
|
||||||
#include "util/ref.h"
|
#include "util/ref.h"
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -20,11 +19,17 @@ typedef enum {
|
|||||||
ASSET_ENTRY_STATE_ERROR
|
ASSET_ENTRY_STATE_ERROR
|
||||||
} assetentrystate_t;
|
} assetentrystate_t;
|
||||||
|
|
||||||
/** Maximum number of subscribers for each per-entry event. */
|
|
||||||
#define ASSET_ENTRY_EVENT_MAX 2
|
|
||||||
|
|
||||||
typedef struct assetentry_s assetentry_t;
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single asset entry callback. Each entry supports at most one subscriber
|
||||||
|
* per event - a second assignment without clearing the first is a bug.
|
||||||
|
*
|
||||||
|
* @param entry The assetentry_t the event fired on.
|
||||||
|
* @param user The user pointer passed alongside the callback.
|
||||||
|
*/
|
||||||
|
typedef void (*assetentrycallback_t)(assetentry_t *entry, void *user);
|
||||||
|
|
||||||
struct assetentry_s {
|
struct assetentry_s {
|
||||||
char_t name[ASSET_FILE_NAME_MAX];
|
char_t name[ASSET_FILE_NAME_MAX];
|
||||||
assetloadertype_t type;
|
assetloadertype_t type;
|
||||||
@@ -33,30 +38,27 @@ struct assetentry_s {
|
|||||||
ref_t refs;
|
ref_t refs;
|
||||||
assetloaderinput_t *input;
|
assetloaderinput_t *input;
|
||||||
assetloaderinput_t inputData;
|
assetloaderinput_t inputData;
|
||||||
/**
|
|
||||||
* Fired once when loading completes successfully (params = assetentry_t *).
|
|
||||||
* Always invoked on the main thread.
|
|
||||||
*/
|
|
||||||
event_t onLoaded;
|
|
||||||
eventcallback_t onLoadedCallbacks[ASSET_ENTRY_EVENT_MAX];
|
|
||||||
void *onLoadedUsers[ASSET_ENTRY_EVENT_MAX];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired once when the entry is disposed/reaped (params = assetentry_t *).
|
* Fired once when loading completes successfully.
|
||||||
* The asset data is still accessible when the callback runs.
|
|
||||||
* Always invoked on the main thread.
|
* Always invoked on the main thread.
|
||||||
*/
|
*/
|
||||||
event_t onUnloaded;
|
assetentrycallback_t onLoaded;
|
||||||
eventcallback_t onUnloadedCallbacks[ASSET_ENTRY_EVENT_MAX];
|
void *onLoadedUser;
|
||||||
void *onUnloadedUsers[ASSET_ENTRY_EVENT_MAX];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired once when loading fails (params = assetentry_t *).
|
* Fired once when the entry is disposed/reaped. The asset data is still
|
||||||
|
* accessible when the callback runs. Always invoked on the main thread.
|
||||||
|
*/
|
||||||
|
assetentrycallback_t onUnloaded;
|
||||||
|
void *onUnloadedUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired once when loading fails.
|
||||||
* Always invoked on the main thread.
|
* Always invoked on the main thread.
|
||||||
*/
|
*/
|
||||||
event_t onError;
|
assetentrycallback_t onError;
|
||||||
eventcallback_t onErrorCallbacks[ASSET_ENTRY_EVENT_MAX];
|
void *onErrorUser;
|
||||||
void *onErrorUsers[ASSET_ENTRY_EVENT_MAX];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,4 +51,22 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
|
|||||||
.loadAsync = assetChunkLoaderAsync,
|
.loadAsync = assetChunkLoaderAsync,
|
||||||
.dispose = assetChunkDispose
|
.dispose = assetChunkDispose
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[ASSET_LOADER_TYPE_CUTSCENE] = {
|
||||||
|
.loadSync = assetCutsceneLoaderSync,
|
||||||
|
.loadAsync = assetCutsceneLoaderAsync,
|
||||||
|
.dispose = assetCutsceneDispose
|
||||||
|
},
|
||||||
|
|
||||||
|
[ASSET_LOADER_TYPE_WAV] = {
|
||||||
|
.loadSync = assetWavLoaderSync,
|
||||||
|
.loadAsync = assetWavLoaderAsync,
|
||||||
|
.dispose = assetWavDispose
|
||||||
|
},
|
||||||
|
|
||||||
|
[ASSET_LOADER_TYPE_MP3] = {
|
||||||
|
.loadSync = assetMp3LoaderSync,
|
||||||
|
.loadAsync = assetMp3LoaderAsync,
|
||||||
|
.dispose = assetMp3Dispose
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
#include "asset/loader/locale/assetlocaleloader.h"
|
#include "asset/loader/locale/assetlocaleloader.h"
|
||||||
#include "asset/loader/json/assetjsonloader.h"
|
#include "asset/loader/json/assetjsonloader.h"
|
||||||
#include "asset/loader/chunk/assetchunkloader.h"
|
#include "asset/loader/chunk/assetchunkloader.h"
|
||||||
|
#include "asset/loader/cutscene/assetcutsceneloader.h"
|
||||||
|
#include "asset/loader/wav/assetwavloader.h"
|
||||||
|
#include "asset/loader/mp3/assetmp3loader.h"
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
ASSET_LOADER_TYPE_NULL,
|
ASSET_LOADER_TYPE_NULL,
|
||||||
@@ -24,6 +27,9 @@ typedef enum {
|
|||||||
ASSET_LOADER_TYPE_LOCALE,
|
ASSET_LOADER_TYPE_LOCALE,
|
||||||
ASSET_LOADER_TYPE_JSON,
|
ASSET_LOADER_TYPE_JSON,
|
||||||
ASSET_LOADER_TYPE_CHUNK,
|
ASSET_LOADER_TYPE_CHUNK,
|
||||||
|
ASSET_LOADER_TYPE_CUTSCENE,
|
||||||
|
ASSET_LOADER_TYPE_WAV,
|
||||||
|
ASSET_LOADER_TYPE_MP3,
|
||||||
|
|
||||||
ASSET_LOADER_TYPE_COUNT
|
ASSET_LOADER_TYPE_COUNT
|
||||||
} assetloadertype_t;
|
} assetloadertype_t;
|
||||||
@@ -36,6 +42,9 @@ typedef union {
|
|||||||
assetlocaleloaderloading_t locale;
|
assetlocaleloaderloading_t locale;
|
||||||
assetjsonloaderloading_t json;
|
assetjsonloaderloading_t json;
|
||||||
assetchunkloaderloading_t chunk;
|
assetchunkloaderloading_t chunk;
|
||||||
|
assetcutsceneloaderloading_t cutscene;
|
||||||
|
assetwavloaderloading_t wav;
|
||||||
|
assetmp3loaderloading_t mp3;
|
||||||
} assetloaderloading_t;
|
} assetloaderloading_t;
|
||||||
|
|
||||||
typedef union {
|
typedef union {
|
||||||
@@ -46,6 +55,9 @@ typedef union {
|
|||||||
assetlocaleoutput_t locale;
|
assetlocaleoutput_t locale;
|
||||||
assetjsonoutput_t json;
|
assetjsonoutput_t json;
|
||||||
assetchunkoutput_t chunk;
|
assetchunkoutput_t chunk;
|
||||||
|
assetcutsceneoutput_t cutscene;
|
||||||
|
assetwavoutput_t wav;
|
||||||
|
assetmp3output_t mp3;
|
||||||
} assetloaderoutput_t;
|
} assetloaderoutput_t;
|
||||||
|
|
||||||
typedef union {
|
typedef union {
|
||||||
@@ -54,6 +66,9 @@ typedef union {
|
|||||||
assetlocaleloaderinput_t locale;
|
assetlocaleloaderinput_t locale;
|
||||||
assetjsonloaderinput_t json;
|
assetjsonloaderinput_t json;
|
||||||
assetchunkloaderinput_t chunk;
|
assetchunkloaderinput_t chunk;
|
||||||
|
assetcutsceneloaderinput_t cutscene;
|
||||||
|
assetwavloaderinput_t wav;
|
||||||
|
assetmp3loaderinput_t mp3;
|
||||||
} assetloaderinput_t;
|
} assetloaderinput_t;
|
||||||
|
|
||||||
typedef struct assetloading_s assetloading_t;
|
typedef struct assetloading_s assetloading_t;
|
||||||
|
|||||||
@@ -8,40 +8,284 @@
|
|||||||
#include "assetchunkloader.h"
|
#include "assetchunkloader.h"
|
||||||
#include "assert/assert.h"
|
#include "assert/assert.h"
|
||||||
#include "util/memory.h"
|
#include "util/memory.h"
|
||||||
#include "util/endian.h"
|
#include "util/string.h"
|
||||||
|
#include "asset/asset.h"
|
||||||
#include "asset/loader/assetloading.h"
|
#include "asset/loader/assetloading.h"
|
||||||
#include "asset/loader/assetentry.h"
|
#include "asset/loader/assetentry.h"
|
||||||
#include "asset/loader/assetloader.h"
|
#include "asset/loader/assetloader.h"
|
||||||
#include "asset/asset.h"
|
#include "asset/loader/json/assetjsonloader.h"
|
||||||
|
#include "display/color.h"
|
||||||
|
#include "display/texture/texture.h"
|
||||||
|
#include "rpg/overworld/worldpos.h"
|
||||||
|
#include "yyjson.h"
|
||||||
|
|
||||||
|
#define ASSET_CHUNK_TERRAIN_TEXTURE "tiles.png"
|
||||||
|
|
||||||
|
// Per-shape corner Z offsets (sw, se, ne, nw) as offsets from a tile's own
|
||||||
|
// base Z, ported 1:1 from tools/asset/chunk/__main__.py's _RAMP_CORNERS.
|
||||||
|
// TILE_SHAPE_NULL's row is never read - assetChunkTilesHaveTerrain/
|
||||||
|
// assetChunkBuildTerrain skip those tiles outright.
|
||||||
|
static const float_t CHUNK_RAMP_CORNERS[TILE_SHAPE_COUNT][4] = {
|
||||||
|
[TILE_SHAPE_GROUND] = {0.0f, 0.0f, 0.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_NORTH] = {0.0f, 0.0f, 1.0f, 1.0f},
|
||||||
|
[TILE_SHAPE_RAMP_SOUTH] = {1.0f, 1.0f, 0.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_EAST] = {0.0f, 1.0f, 1.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_WEST] = {1.0f, 0.0f, 0.0f, 1.0f},
|
||||||
|
[TILE_SHAPE_RAMP_NORTHEAST] = {0.0f, 0.0f, 1.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_NORTHWEST] = {0.0f, 0.0f, 0.0f, 1.0f},
|
||||||
|
[TILE_SHAPE_RAMP_SOUTHEAST] = {0.0f, 1.0f, 0.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_SOUTHWEST] = {1.0f, 0.0f, 0.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_NORTHEAST_INNER] = {0.0f, 1.0f, 1.0f, 1.0f},
|
||||||
|
[TILE_SHAPE_RAMP_NORTHWEST_INNER] = {1.0f, 0.0f, 1.0f, 1.0f},
|
||||||
|
[TILE_SHAPE_RAMP_SOUTHEAST_INNER] = {1.0f, 1.0f, 1.0f, 0.0f},
|
||||||
|
[TILE_SHAPE_RAMP_SOUTHWEST_INNER] = {1.0f, 1.0f, 0.0f, 1.0f},
|
||||||
|
};
|
||||||
|
|
||||||
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
|
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
|
||||||
assertNotNull(loading, "Loading cannot be NULL");
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
assertNotMainThread("Should be called from an async thread.");
|
assertNotMainThread("Should be called from an async thread.");
|
||||||
|
|
||||||
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNull(loading->loading.chunk.data, "Data already defined?");
|
static bool_t assetChunkTilesHaveTerrain(const tile_t *tiles) {
|
||||||
|
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
|
||||||
|
if(tiles[i].shape != TILE_SHAPE_NULL) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
assetfile_t *file = &loading->loading.chunk.file;
|
// Reads the "tiles" array - a dense, fixed-size CHUNK_TILE_COUNT grid,
|
||||||
assetLoaderErrorChain(loading,
|
// one slot per (x, y) column at index x + y * CHUNK_WIDTH (position is
|
||||||
assetFileInit(file, loading->entry->name, NULL, NULL)
|
// implicit in the index, not stored). Each slot is either `null` (empty
|
||||||
|
// column, already zeroed = TILE_SHAPE_NULL) or a 2-element [type, z]
|
||||||
|
// array.
|
||||||
|
static void assetChunkParseTiles(yyjson_val *root, tile_t *tiles) {
|
||||||
|
memoryZero(tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
|
||||||
|
|
||||||
|
yyjson_val *tilesArr = yyjson_obj_get(root, "tiles");
|
||||||
|
if(!yyjson_is_arr(tilesArr)) return;
|
||||||
|
|
||||||
|
size_t idx, len;
|
||||||
|
yyjson_val *tileVal;
|
||||||
|
yyjson_arr_foreach(tilesArr, idx, len, tileVal) {
|
||||||
|
if(idx >= CHUNK_TILE_COUNT) break;
|
||||||
|
if(!yyjson_is_arr(tileVal) || yyjson_arr_size(tileVal) < 2) continue;
|
||||||
|
|
||||||
|
int32_t type = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 0));
|
||||||
|
int32_t z = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 1));
|
||||||
|
tiles[idx] = (tile_t){ .shape = (tileshape_t)type, .z = (uint8_t)z };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves "meshes" (external building references, by bare filename) into
|
||||||
|
// out->modelNames/meshOffsets starting at *ioIndex, advancing it as
|
||||||
|
// entries are written. Filenames are resolved as
|
||||||
|
// "models/buildings/<basename>.json" - a hardcoded stand-in for
|
||||||
|
// tools/asset/chunk/__main__.py's find_model() recursive search, valid
|
||||||
|
// as long as every building model lives flat under that one directory
|
||||||
|
// (true today - see the raw-json-maps plan notes for the tradeoff).
|
||||||
|
static void assetChunkParseMeshes(
|
||||||
|
yyjson_val *root, assetchunkoutput_t *out, uint8_t *ioIndex
|
||||||
|
) {
|
||||||
|
yyjson_val *meshesArr = yyjson_obj_get(root, "meshes");
|
||||||
|
if(!yyjson_is_arr(meshesArr)) return;
|
||||||
|
|
||||||
|
size_t idx, len;
|
||||||
|
yyjson_val *meshVal;
|
||||||
|
yyjson_arr_foreach(meshesArr, idx, len, meshVal) {
|
||||||
|
if(*ioIndex >= CHUNK_MESH_COUNT_MAX) break;
|
||||||
|
|
||||||
|
yyjson_val *fileVal = yyjson_obj_get(meshVal, "file");
|
||||||
|
if(!fileVal || !yyjson_is_str(fileVal)) continue;
|
||||||
|
|
||||||
|
char_t baseName[CHUNK_MESH_NAME_MAX];
|
||||||
|
stringCopy(baseName, yyjson_get_str(fileVal), sizeof(baseName));
|
||||||
|
char_t *dot = stringFindLastChar(baseName, '.');
|
||||||
|
if(dot != NULL) *dot = '\0';
|
||||||
|
|
||||||
|
uint8_t m = *ioIndex;
|
||||||
|
stringFormat(
|
||||||
|
out->modelNames[m], CHUNK_MESH_NAME_MAX,
|
||||||
|
"models/buildings/%s.json", baseName
|
||||||
);
|
);
|
||||||
|
|
||||||
uint8_t *data = memoryAllocate(file->size);
|
vec3 offset = {0.0f, 0.0f, 0.0f};
|
||||||
assetLoaderErrorChain(loading, assetFileOpen(file));
|
yyjson_val *posArr = yyjson_obj_get(meshVal, "pos");
|
||||||
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
|
if(yyjson_is_arr(posArr) && yyjson_arr_size(posArr) >= 3) {
|
||||||
assetLoaderErrorChain(loading, assetFileClose(file));
|
offset[0] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 0));
|
||||||
assetLoaderErrorChain(loading, assetFileDispose(file));
|
offset[1] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 1));
|
||||||
assertTrue(
|
offset[2] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 2));
|
||||||
file->lastRead == file->size,
|
}
|
||||||
"Failed to read entire chunk file."
|
memoryCopy(out->meshOffsets[m], offset, sizeof(vec3));
|
||||||
|
|
||||||
|
(*ioIndex)++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void assetChunkParseEntities(yyjson_val *root, assetchunkoutput_t *out) {
|
||||||
|
out->entitySpawnCount = 0;
|
||||||
|
|
||||||
|
yyjson_val *arr = yyjson_obj_get(root, "entities");
|
||||||
|
if(!yyjson_is_arr(arr)) return;
|
||||||
|
|
||||||
|
size_t idx, len;
|
||||||
|
yyjson_val *val;
|
||||||
|
yyjson_arr_foreach(arr, idx, len, val) {
|
||||||
|
if(out->entitySpawnCount >= CHUNK_ENTITY_SPAWN_COUNT_MAX) break;
|
||||||
|
|
||||||
|
const char_t *type = yyjson_get_str(yyjson_obj_get(val, "type"));
|
||||||
|
yyjson_val *posArr = yyjson_obj_get(val, "pos");
|
||||||
|
if(type == NULL || !yyjson_is_arr(posArr) || yyjson_arr_size(posArr) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
worldpos_t pos = {
|
||||||
|
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 0)),
|
||||||
|
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 1)),
|
||||||
|
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
chunkentityspawn_t *spawn = &out->entitySpawns[out->entitySpawnCount];
|
||||||
|
if(stringEquals(type, "global")) {
|
||||||
|
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_GLOBAL;
|
||||||
|
spawn->globalId =
|
||||||
|
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "globalId"));
|
||||||
|
spawn->itemId = 0;
|
||||||
|
spawn->itemQuantity = 0;
|
||||||
|
} else if(stringEquals(type, "item")) {
|
||||||
|
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_ITEM;
|
||||||
|
spawn->globalId = 0;
|
||||||
|
spawn->itemId = (uint16_t)yyjson_get_int(yyjson_obj_get(val, "itemId"));
|
||||||
|
spawn->itemQuantity =
|
||||||
|
(uint8_t)yyjson_get_int(yyjson_obj_get(val, "quantity"));
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
spawn->position = pos;
|
||||||
|
out->entitySpawnCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void assetChunkParseAreas(yyjson_val *root, assetchunkoutput_t *out) {
|
||||||
|
out->areaSpawnCount = 0;
|
||||||
|
|
||||||
|
yyjson_val *arr = yyjson_obj_get(root, "areas");
|
||||||
|
if(!yyjson_is_arr(arr)) return;
|
||||||
|
|
||||||
|
size_t idx, len;
|
||||||
|
yyjson_val *val;
|
||||||
|
yyjson_arr_foreach(arr, idx, len, val) {
|
||||||
|
if(out->areaSpawnCount >= CHUNK_AREA_COUNT_MAX) break;
|
||||||
|
|
||||||
|
yyjson_val *minArr = yyjson_obj_get(val, "min");
|
||||||
|
yyjson_val *maxArr = yyjson_obj_get(val, "max");
|
||||||
|
if(
|
||||||
|
!yyjson_is_arr(minArr) || yyjson_arr_size(minArr) < 3 ||
|
||||||
|
!yyjson_is_arr(maxArr) || yyjson_arr_size(maxArr) < 3
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
chunkareaspawn_t *area = &out->areaSpawns[out->areaSpawnCount];
|
||||||
|
area->min = (worldpos_t){
|
||||||
|
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 0)),
|
||||||
|
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 1)),
|
||||||
|
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 2)),
|
||||||
|
};
|
||||||
|
area->max = (worldpos_t){
|
||||||
|
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 0)),
|
||||||
|
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 1)),
|
||||||
|
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 2)),
|
||||||
|
};
|
||||||
|
area->callbackId =
|
||||||
|
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "callbackId"));
|
||||||
|
area->notify = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "notify"));
|
||||||
|
area->trigger = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "trigger"));
|
||||||
|
out->areaSpawnCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six vertices (2 CCW triangles) for one tile's quad, matching
|
||||||
|
// tools/asset/chunk/__main__.py's _tile_quad. Corner order: SW=(x,y),
|
||||||
|
// SE=(x+1,y), NE=(x+1,y+1), NW=(x,y+1). NORTH=+Y, EAST=+X.
|
||||||
|
static void assetChunkAppendTileQuad(
|
||||||
|
meshvertex_t *verts, uint32_t *vertCount,
|
||||||
|
const uint32_t x, const uint32_t y,
|
||||||
|
const float_t sw, const float_t se, const float_t ne, const float_t nw
|
||||||
|
) {
|
||||||
|
const float_t u0 = (float_t)x / (float_t)CHUNK_WIDTH;
|
||||||
|
const float_t u1 = (float_t)(x + 1) / (float_t)CHUNK_WIDTH;
|
||||||
|
const float_t v0 = (float_t)y / (float_t)CHUNK_HEIGHT;
|
||||||
|
const float_t v1 = (float_t)(y + 1) / (float_t)CHUNK_HEIGHT;
|
||||||
|
const float_t fx = (float_t)x;
|
||||||
|
const float_t fy = (float_t)y;
|
||||||
|
|
||||||
|
meshvertex_t *v = verts + *vertCount;
|
||||||
|
v[0] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
|
||||||
|
v[1] = (meshvertex_t){ .uv = {u1, v0}, .pos = {fx + 1, fy, se} };
|
||||||
|
v[2] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
|
||||||
|
v[3] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
|
||||||
|
v[4] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
|
||||||
|
v[5] = (meshvertex_t){ .uv = {u0, v1}, .pos = {fx, fy + 1, nw} };
|
||||||
|
*vertCount += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a mesh_t straight from the tile grid - one quad per non-null
|
||||||
|
// tile column, shaped by CHUNK_RAMP_CORNERS - the runtime equivalent of
|
||||||
|
// tools/asset/chunk/__main__.py's build_terrain_verts. Only called once
|
||||||
|
// assetChunkTilesHaveTerrain has confirmed at least one real tile exists.
|
||||||
|
// Mirrors assetMeshLoaderSync's meshInit/meshFlush/free-after-upload
|
||||||
|
// sequence (assetmeshloader.c) since this produces the same
|
||||||
|
// assetmeshoutput_t shape, just from generated verts instead of a file.
|
||||||
|
static errorret_t assetChunkBuildTerrain(
|
||||||
|
const tile_t *tiles, assetmeshoutput_t *out
|
||||||
|
) {
|
||||||
|
meshvertex_t *verts = memoryAllocate(
|
||||||
|
CHUNK_TILE_COUNT * 6 * sizeof(meshvertex_t)
|
||||||
);
|
);
|
||||||
|
uint32_t vertCount = 0;
|
||||||
|
|
||||||
|
for(uint32_t y = 0; y < CHUNK_HEIGHT; y++) {
|
||||||
|
for(uint32_t x = 0; x < CHUNK_WIDTH; x++) {
|
||||||
|
const tile_t *tile = &tiles[x + y * CHUNK_WIDTH];
|
||||||
|
if(tile->shape == TILE_SHAPE_NULL) continue;
|
||||||
|
|
||||||
|
const float_t *corners = CHUNK_RAMP_CORNERS[tile->shape];
|
||||||
|
const float_t fz = (float_t)tile->z;
|
||||||
|
assetChunkAppendTileQuad(
|
||||||
|
verts, &vertCount, x, y,
|
||||||
|
(fz + corners[0]) * WORLD_LAYER_HEIGHT,
|
||||||
|
(fz + corners[1]) * WORLD_LAYER_HEIGHT,
|
||||||
|
(fz + corners[2]) * WORLD_LAYER_HEIGHT,
|
||||||
|
(fz + corners[3]) * WORLD_LAYER_HEIGHT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(vertCount > 0, "Terrain build called with no terrain tiles.");
|
||||||
|
|
||||||
|
out->vertices = verts;
|
||||||
|
errorret_t ret = meshInit(
|
||||||
|
&out->mesh, MESH_PRIMITIVE_TYPE_TRIANGLES, (int32_t)vertCount, verts
|
||||||
|
);
|
||||||
|
if(errorIsNotOk(ret)) {
|
||||||
|
memoryFree(out->vertices);
|
||||||
|
out->vertices = NULL;
|
||||||
|
errorChain(ret);
|
||||||
|
}
|
||||||
|
out->meshInitialized = true;
|
||||||
|
|
||||||
|
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
|
||||||
|
if(errorIsNotOk(ret)) {
|
||||||
|
meshDispose(&out->mesh);
|
||||||
|
out->meshInitialized = false;
|
||||||
|
memoryFree(out->vertices);
|
||||||
|
out->vertices = NULL;
|
||||||
|
errorChain(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
|
||||||
|
memoryFree(out->vertices);
|
||||||
|
out->vertices = NULL;
|
||||||
|
#endif
|
||||||
|
|
||||||
loading->loading.chunk.data = data;
|
|
||||||
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +298,8 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
|||||||
|
|
||||||
switch(loading->loading.chunk.state) {
|
switch(loading->loading.chunk.state) {
|
||||||
case ASSET_CHUNK_LOADING_STATE_INITIAL:
|
case ASSET_CHUNK_LOADING_STATE_INITIAL:
|
||||||
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_READ_FILE;
|
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
errorOk();
|
errorOk();
|
||||||
|
|
||||||
case ASSET_CHUNK_LOADING_STATE_PARSE:
|
case ASSET_CHUNK_LOADING_STATE_PARSE:
|
||||||
@@ -68,9 +312,7 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
|||||||
out->modelEntries[m] = assetLock(
|
out->modelEntries[m] = assetLock(
|
||||||
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
|
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
|
||||||
);
|
);
|
||||||
assertNotNull(
|
assertNotNull(out->modelEntries[m], "Failed to lock model.");
|
||||||
out->modelEntries[m], "Failed to lock model."
|
|
||||||
);
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
@@ -92,62 +334,95 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
|||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t *data = loading->loading.chunk.data;
|
// --- PARSE: lock+require the chunk's own JSON as a sub-asset, exactly
|
||||||
assertNotNull(data, "Chunk data should have been loaded by now.");
|
// like assetmodelloader.c's ASSET_MODEL_LOADING_STATE_LOCK_ASSETS does
|
||||||
|
// for its model descriptor, then read everything out of it before
|
||||||
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'F') {
|
// unlocking (the yyjson_doc/vals only stay valid while jsonEntry does).
|
||||||
memoryFree(data);
|
char_t jsonKey[ASSET_FILE_NAME_MAX];
|
||||||
assetLoaderErrorThrow(loading, "Invalid chunk file header");
|
stringFormat(jsonKey, sizeof(jsonKey), "json:%s", loading->entry->name);
|
||||||
}
|
assetloaderinput_t jsonInput;
|
||||||
|
memoryZero(&jsonInput, sizeof(jsonInput));
|
||||||
uint32_t version = endianLittleToHost32(*(uint32_t *)(data + 4));
|
stringCopy(
|
||||||
if(version != ASSET_CHUNK_FILE_VERSION) {
|
jsonInput.json.path, loading->entry->name, ASSET_FILE_NAME_MAX
|
||||||
memoryFree(data);
|
);
|
||||||
|
assetentry_t *jsonEntry =
|
||||||
|
assetLock(jsonKey, ASSET_LOADER_TYPE_JSON, &jsonInput);
|
||||||
|
errorret_t jsonRet = assetRequireLoaded(jsonEntry);
|
||||||
|
if(errorIsNotOk(jsonRet)) {
|
||||||
|
assetUnlockEntry(jsonEntry);
|
||||||
assetLoaderErrorThrow(
|
assetLoaderErrorThrow(
|
||||||
loading, "Unsupported chunk file version %u", version
|
loading, "Failed to load chunk JSON: %s", loading->entry->name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t offset = 8;
|
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
|
||||||
|
|
||||||
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
|
tile_t tiles[CHUNK_TILE_COUNT];
|
||||||
out->tiles = memoryAllocate(tileSize);
|
assetChunkParseTiles(root, tiles);
|
||||||
memoryCopy(out->tiles, data + offset, tileSize);
|
out->hasTerrain = assetChunkTilesHaveTerrain(tiles);
|
||||||
offset += tileSize;
|
|
||||||
|
|
||||||
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
|
uint8_t meshIndex = out->hasTerrain ? 1 : 0;
|
||||||
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
|
assetChunkParseMeshes(root, out, &meshIndex);
|
||||||
*shape = endianLittleToHost32(*shape);
|
out->meshCount = meshIndex;
|
||||||
}
|
|
||||||
|
|
||||||
out->meshCount = data[offset];
|
assetChunkParseEntities(root, out);
|
||||||
offset += sizeof(uint8_t);
|
assetChunkParseAreas(root, out);
|
||||||
assertTrue(
|
|
||||||
out->meshCount <= CHUNK_MESH_COUNT_MAX,
|
assetUnlockEntry(jsonEntry);
|
||||||
"Chunk mesh count exceeds maximum."
|
|
||||||
|
out->tiles = memoryAllocate(CHUNK_TILE_COUNT * sizeof(tile_t));
|
||||||
|
memoryCopy(out->tiles, tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
|
||||||
|
|
||||||
|
// Terrain: build the mesh, then wrap it in synthetic (non-cache,
|
||||||
|
// non-refcounted) mesh/model entries so modelEntries[0] behaves exactly
|
||||||
|
// like a real file-backed model entry for every downstream consumer -
|
||||||
|
// see the header's doc comment on hasTerrain/terrainMeshEntry.
|
||||||
|
if(out->hasTerrain) {
|
||||||
|
assetmeshoutput_t terrainMesh;
|
||||||
|
memoryZero(&terrainMesh, sizeof(terrainMesh));
|
||||||
|
errorret_t terrainRet = assetChunkBuildTerrain(out->tiles, &terrainMesh);
|
||||||
|
if(errorIsNotOk(terrainRet)) {
|
||||||
|
memoryFree(out->tiles);
|
||||||
|
out->tiles = NULL;
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading, "Failed to build chunk terrain mesh: %s",
|
||||||
|
loading->entry->name
|
||||||
);
|
);
|
||||||
|
|
||||||
for(uint8_t m = 0; m < out->meshCount; m++) {
|
|
||||||
uint8_t nameLen = 0;
|
|
||||||
while(
|
|
||||||
data[offset + nameLen] != '\0' &&
|
|
||||||
nameLen < CHUNK_MESH_NAME_MAX - 1
|
|
||||||
) {
|
|
||||||
nameLen++;
|
|
||||||
}
|
|
||||||
memoryCopy(out->modelNames[m], data + offset, nameLen);
|
|
||||||
out->modelNames[m][nameLen] = '\0';
|
|
||||||
offset += nameLen + 1;
|
|
||||||
|
|
||||||
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
|
|
||||||
offset += sizeof(vec3);
|
|
||||||
out->meshOffsets[m][0] = endianLittleToHostFloat(out->meshOffsets[m][0]);
|
|
||||||
out->meshOffsets[m][1] = endianLittleToHostFloat(out->meshOffsets[m][1]);
|
|
||||||
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
memoryFree(data);
|
assetloaderinput_t texInput = { .texture = TEXTURE_FORMAT_RGBA };
|
||||||
loading->loading.chunk.data = NULL;
|
assetentry_t *texEntry = assetLock(
|
||||||
|
ASSET_CHUNK_TERRAIN_TEXTURE, ASSET_LOADER_TYPE_TEXTURE, &texInput
|
||||||
|
);
|
||||||
|
errorret_t texRet = assetRequireLoaded(texEntry);
|
||||||
|
if(errorIsNotOk(texRet)) {
|
||||||
|
assetUnlockEntry(texEntry);
|
||||||
|
meshDispose(&terrainMesh.mesh);
|
||||||
|
memoryFree(out->tiles);
|
||||||
|
out->tiles = NULL;
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading, "Failed to load chunk terrain texture: %s",
|
||||||
|
loading->entry->name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out->terrainTexEntry = texEntry;
|
||||||
|
|
||||||
|
out->terrainMeshEntry = memoryAllocate(sizeof(assetentry_t));
|
||||||
|
memoryZero(out->terrainMeshEntry, sizeof(assetentry_t));
|
||||||
|
out->terrainMeshEntry->type = ASSET_LOADER_TYPE_MESH;
|
||||||
|
out->terrainMeshEntry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
|
out->terrainMeshEntry->data.mesh = terrainMesh;
|
||||||
|
|
||||||
|
out->terrainModelEntry = memoryAllocate(sizeof(assetentry_t));
|
||||||
|
memoryZero(out->terrainModelEntry, sizeof(assetentry_t));
|
||||||
|
out->terrainModelEntry->type = ASSET_LOADER_TYPE_MODEL;
|
||||||
|
out->terrainModelEntry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
|
out->terrainModelEntry->data.model.meshEntry = out->terrainMeshEntry;
|
||||||
|
out->terrainModelEntry->data.model.texEntry = out->terrainTexEntry;
|
||||||
|
out->terrainModelEntry->data.model.color = COLOR_WHITE;
|
||||||
|
|
||||||
|
out->modelEntries[0] = out->terrainModelEntry;
|
||||||
|
}
|
||||||
|
|
||||||
if(out->meshCount == 0) {
|
if(out->meshCount == 0) {
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
@@ -155,7 +430,7 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS;
|
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS;
|
||||||
loading->loading.chunk.modelIndex = 0;
|
loading->loading.chunk.modelIndex = out->hasTerrain ? 1 : 0;
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
@@ -174,8 +449,36 @@ errorret_t assetChunkDispose(assetentry_t *entry) {
|
|||||||
|
|
||||||
for(uint8_t m = 0; m < out->meshCount; m++) {
|
for(uint8_t m = 0; m < out->meshCount; m++) {
|
||||||
if(out->modelEntries[m] == NULL) continue;
|
if(out->modelEntries[m] == NULL) continue;
|
||||||
|
if(out->hasTerrain && m == 0) continue; // synthetic - freed below.
|
||||||
assetUnlockEntry(out->modelEntries[m]);
|
assetUnlockEntry(out->modelEntries[m]);
|
||||||
out->modelEntries[m] = NULL;
|
out->modelEntries[m] = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(out->hasTerrain) {
|
||||||
|
if(out->terrainMeshEntry != NULL) {
|
||||||
|
assetmeshoutput_t *meshOut = &out->terrainMeshEntry->data.mesh;
|
||||||
|
if(meshOut->meshInitialized) {
|
||||||
|
meshDispose(&meshOut->mesh);
|
||||||
|
meshOut->meshInitialized = false;
|
||||||
|
}
|
||||||
|
if(meshOut->vertices != NULL) {
|
||||||
|
memoryFree(meshOut->vertices);
|
||||||
|
meshOut->vertices = NULL;
|
||||||
|
}
|
||||||
|
memoryFree(out->terrainMeshEntry);
|
||||||
|
out->terrainMeshEntry = NULL;
|
||||||
|
}
|
||||||
|
if(out->terrainModelEntry != NULL) {
|
||||||
|
memoryFree(out->terrainModelEntry);
|
||||||
|
out->terrainModelEntry = NULL;
|
||||||
|
}
|
||||||
|
if(out->terrainTexEntry != NULL) {
|
||||||
|
assetUnlockEntry(out->terrainTexEntry);
|
||||||
|
out->terrainTexEntry = NULL;
|
||||||
|
}
|
||||||
|
out->modelEntries[0] = NULL;
|
||||||
|
out->hasTerrain = false;
|
||||||
|
}
|
||||||
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
#include "asset/assetfile.h"
|
#include "asset/assetfile.h"
|
||||||
#include "rpg/overworld/chunk.h"
|
#include "rpg/overworld/chunk.h"
|
||||||
|
|
||||||
#define ASSET_CHUNK_FILE_VERSION 4
|
|
||||||
|
|
||||||
typedef struct assetloading_s assetloading_t;
|
typedef struct assetloading_s assetloading_t;
|
||||||
typedef struct assetentry_s assetentry_t;
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
@@ -20,31 +18,81 @@ typedef struct {
|
|||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
ASSET_CHUNK_LOADING_STATE_INITIAL,
|
ASSET_CHUNK_LOADING_STATE_INITIAL,
|
||||||
ASSET_CHUNK_LOADING_STATE_READ_FILE,
|
|
||||||
ASSET_CHUNK_LOADING_STATE_PARSE,
|
ASSET_CHUNK_LOADING_STATE_PARSE,
|
||||||
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
|
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
|
||||||
ASSET_CHUNK_LOADING_STATE_DONE
|
ASSET_CHUNK_LOADING_STATE_DONE
|
||||||
} assetchunkloadingstate_t;
|
} assetchunkloadingstate_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
assetfile_t file;
|
|
||||||
assetchunkloadingstate_t state;
|
assetchunkloadingstate_t state;
|
||||||
uint8_t *data;
|
|
||||||
|
// Index into assetchunkoutput_t.modelNames/modelEntries the external
|
||||||
|
// (non-terrain) model polling loop has reached - see
|
||||||
|
// ASSET_CHUNK_LOADING_STATE_LOAD_MODELS.
|
||||||
uint8_t modelIndex;
|
uint8_t modelIndex;
|
||||||
} assetchunkloaderloading_t;
|
} assetchunkloaderloading_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
|
||||||
|
CHUNK_ENTITY_SPAWN_KIND_ITEM
|
||||||
|
} chunkentityspawnkind_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
chunkentityspawnkind_t kind;
|
||||||
|
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
|
||||||
|
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
||||||
|
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
||||||
|
worldpos_t position;
|
||||||
|
} chunkentityspawn_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
worldpos_t min;
|
||||||
|
worldpos_t max;
|
||||||
|
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
|
||||||
|
uint8_t notify;
|
||||||
|
uint8_t trigger;
|
||||||
|
} chunkareaspawn_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
tile_t *tiles;
|
tile_t *tiles;
|
||||||
uint8_t meshCount;
|
uint8_t meshCount;
|
||||||
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
|
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
|
||||||
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
|
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
|
||||||
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
|
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
|
||||||
|
|
||||||
|
// Terrain mesh generated at load time from the tile grid (no baked
|
||||||
|
// .dmf/.json - see assetChunkBuildTerrain in assetchunkloader.c). NULL
|
||||||
|
// (with hasTerrain false) for a chunk with no non-null tiles, in which
|
||||||
|
// case modelEntries[0] (if meshCount > 0) is the first external mesh
|
||||||
|
// instead - matches the old baked pipeline's "only write a terrain
|
||||||
|
// model if it has vertices" behavior.
|
||||||
|
//
|
||||||
|
// terrainMeshEntry/terrainModelEntry are heap-allocated, plain
|
||||||
|
// assetentry_t values that are never passed to assetLock/assetGetEntry/
|
||||||
|
// assetUnlockEntry (only a real, name-keyed, ref-counted entry may be)
|
||||||
|
// - they exist purely so modelEntries[0] can point at something with
|
||||||
|
// the same ->state/->data.model/->data.mesh.mesh shape every other
|
||||||
|
// model/mesh entry has, so chunk.c/map.c/sceneoverworld.c don't need to
|
||||||
|
// know terrain isn't file-backed. Freed directly (not assetUnlockEntry)
|
||||||
|
// in assetChunkDispose. terrainTexEntry is a real, ref-counted texture
|
||||||
|
// entry (the shared "tiles.png") and must be assetUnlockEntry'd there.
|
||||||
|
bool_t hasTerrain;
|
||||||
|
assetentry_t *terrainMeshEntry;
|
||||||
|
assetentry_t *terrainModelEntry;
|
||||||
|
assetentry_t *terrainTexEntry;
|
||||||
|
|
||||||
|
uint8_t entitySpawnCount;
|
||||||
|
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
|
||||||
|
|
||||||
|
uint8_t areaSpawnCount;
|
||||||
|
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
|
||||||
} assetchunkoutput_t;
|
} assetchunkoutput_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronous loader for chunk assets. Reads the raw DCF file bytes into
|
* Async loader stub for chunk assets. Chunk loading is performed entirely
|
||||||
* the loading buffer so the sync phase can parse without blocking the
|
* on the main thread (it delegates to the JSON asset loader and to
|
||||||
* main thread on I/O.
|
* meshInit/meshFlush, both main-thread-only); this callback is never
|
||||||
|
* reached.
|
||||||
*
|
*
|
||||||
* @param loading Loading information for the asset being loaded.
|
* @param loading Loading information for the asset being loaded.
|
||||||
* @return Error code indicating success or failure of the load operation.
|
* @return Error code indicating success or failure of the load operation.
|
||||||
@@ -52,9 +100,11 @@ typedef struct {
|
|||||||
errorret_t assetChunkLoaderAsync(assetloading_t *loading);
|
errorret_t assetChunkLoaderAsync(assetloading_t *loading);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous loader for chunk assets. Validates the DCF binary previously
|
* Synchronous loader for chunk assets. Locks the chunk's JSON descriptor
|
||||||
* read by the async phase and populates the output assetchunkoutput_t with
|
* as a sub-asset, parses tiles/external mesh references/entity spawns/
|
||||||
* tile data and model paths.
|
* area spawns, generates the terrain mesh directly from the tile grid,
|
||||||
|
* and locks/waits for every referenced model (and the terrain's shared
|
||||||
|
* tileset texture) to finish loading.
|
||||||
*
|
*
|
||||||
* @param loading Loading information for the asset being loaded.
|
* @param loading Loading information for the asset being loaded.
|
||||||
* @return Error code indicating success or failure of the load operation.
|
* @return Error code indicating success or failure of the load operation.
|
||||||
|
|||||||
+1
-5
@@ -5,9 +5,5 @@
|
|||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
PUBLIC
|
PUBLIC
|
||||||
uisettings.c
|
assetcutsceneloader.c
|
||||||
uisettingsgeneral.c
|
|
||||||
uisettingsinput.c
|
|
||||||
uisettingsdisplay.c
|
|
||||||
uisettingsaudio.c
|
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "assetcutsceneloader.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/string.h"
|
||||||
|
#include "asset/loader/assetloading.h"
|
||||||
|
#include "asset/loader/assetentry.h"
|
||||||
|
#include "asset/loader/assetloader.h"
|
||||||
|
#include "asset/asset.h"
|
||||||
|
#include "rpg/cutscene/item/json/cutscenejsonpauseflags.h"
|
||||||
|
|
||||||
|
errorret_t assetCutsceneParseItem(
|
||||||
|
assetloading_t *loading,
|
||||||
|
yyjson_val *itemObj,
|
||||||
|
cutsceneitem_t *item,
|
||||||
|
uint8_t *pool,
|
||||||
|
size_t *poolOffset,
|
||||||
|
const uint8_t index
|
||||||
|
) {
|
||||||
|
if(!yyjson_is_obj(itemObj)) {
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading,
|
||||||
|
"Cutscene item %u is not an object",
|
||||||
|
(uint32_t)index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type"));
|
||||||
|
if(!cutsceneItemLookupType(typeName, &item->type)) {
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading,
|
||||||
|
"Cutscene item %u: missing/unsupported item type %s",
|
||||||
|
(uint32_t)index,
|
||||||
|
typeName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
|
||||||
|
if(load == NULL) {
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index,
|
||||||
|
(uint32_t)item->type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t result = load(itemObj, item, pool, poolOffset);
|
||||||
|
if(errorIsNotOk(result)) {
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
|
||||||
|
errorChain(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
|
||||||
|
size_t size = 0;
|
||||||
|
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
|
||||||
|
yyjson_val *itemVal;
|
||||||
|
while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) {
|
||||||
|
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type"));
|
||||||
|
cutsceneitemtype_t type;
|
||||||
|
if(!cutsceneItemLookupType(typeName, &type)) continue;
|
||||||
|
|
||||||
|
cutsceneitempoolsizecallback_t *poolSize =
|
||||||
|
CUTSCENE_ITEM_CALLBACKS[type].poolSize;
|
||||||
|
if(poolSize == NULL) continue;
|
||||||
|
|
||||||
|
size = cutsceneJsonPoolAlign(size) + poolSize(itemVal);
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) {
|
||||||
|
if(out->items != NULL) {
|
||||||
|
memoryFree(out->items);
|
||||||
|
out->items = NULL;
|
||||||
|
}
|
||||||
|
if(out->pool != NULL) {
|
||||||
|
memoryFree(out->pool);
|
||||||
|
out->pool = NULL;
|
||||||
|
}
|
||||||
|
if(out->doc != NULL) {
|
||||||
|
yyjson_doc_free(out->doc);
|
||||||
|
out->doc = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertNotMainThread("Should be called from an async thread.");
|
||||||
|
|
||||||
|
if(
|
||||||
|
loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE
|
||||||
|
) {
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(loading->loading.cutscene.buffer, "Buffer already defined?");
|
||||||
|
|
||||||
|
assetfile_t *file = &loading->loading.cutscene.file;
|
||||||
|
assetLoaderErrorChain(loading,
|
||||||
|
assetFileInit(file, loading->entry->name, NULL, NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) {
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading, "Cutscene JSON exceeds maximum allowed size"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t fileSize = (size_t)file->size;
|
||||||
|
uint8_t *buffer = memoryAllocate(fileSize);
|
||||||
|
assetLoaderErrorChain(loading, assetFileOpen(file));
|
||||||
|
assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize));
|
||||||
|
assertTrue(
|
||||||
|
file->lastRead == file->size,
|
||||||
|
"Failed to read entire cutscene file."
|
||||||
|
);
|
||||||
|
assetLoaderErrorChain(loading, assetFileClose(file));
|
||||||
|
assetLoaderErrorChain(loading, assetFileDispose(file));
|
||||||
|
|
||||||
|
loading->loading.cutscene.buffer = buffer;
|
||||||
|
loading->loading.cutscene.size = fileSize;
|
||||||
|
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
|
||||||
|
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
|
||||||
|
uint8_t *buffer = loading->loading.cutscene.buffer;
|
||||||
|
assertNotNull(buffer, "Cutscene data should have been loaded by now.");
|
||||||
|
size_t bufferSize = loading->loading.cutscene.size;
|
||||||
|
|
||||||
|
yyjson_doc *doc = yyjson_read(
|
||||||
|
(char *)buffer, bufferSize,
|
||||||
|
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
|
||||||
|
);
|
||||||
|
memoryFree(buffer);
|
||||||
|
loading->loading.cutscene.buffer = NULL;
|
||||||
|
|
||||||
|
if(doc == NULL) {
|
||||||
|
assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON");
|
||||||
|
}
|
||||||
|
out->doc = doc;
|
||||||
|
|
||||||
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||||
|
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
|
||||||
|
if(!yyjson_is_arr(itemsArr)) {
|
||||||
|
assetCutsceneFreeParsed(out);
|
||||||
|
assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array");
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t itemCount = yyjson_arr_size(itemsArr);
|
||||||
|
if(itemCount > UINT8_MAX) {
|
||||||
|
assetCutsceneFreeParsed(out);
|
||||||
|
assetLoaderErrorThrow(loading, "Cutscene has more than 255 items");
|
||||||
|
}
|
||||||
|
|
||||||
|
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
|
||||||
|
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
|
||||||
|
if(yyjson_is_arr(pauseArr)) {
|
||||||
|
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
|
||||||
|
assetCutsceneFreeParsed(out);
|
||||||
|
assetLoaderErrorThrow(
|
||||||
|
loading, "Cutscene JSON has an invalid 'pause' flag"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
|
||||||
|
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
|
||||||
|
|
||||||
|
size_t poolSize = assetCutsceneComputePoolSize(itemsArr);
|
||||||
|
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
|
||||||
|
|
||||||
|
size_t poolOffset = 0;
|
||||||
|
yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr);
|
||||||
|
yyjson_val *itemVal;
|
||||||
|
uint8_t index = 0;
|
||||||
|
while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) {
|
||||||
|
errorret_t itemResult = assetCutsceneParseItem(
|
||||||
|
loading, itemVal, &out->items[index], out->pool, &poolOffset, index
|
||||||
|
);
|
||||||
|
if(errorIsNotOk(itemResult)) {
|
||||||
|
assetCutsceneFreeParsed(out);
|
||||||
|
errorChain(itemResult);
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->cutscene.items = out->items;
|
||||||
|
out->cutscene.itemCount = (uint8_t)itemCount;
|
||||||
|
out->cutscene.pause = pause;
|
||||||
|
out->cutscene.dataSize = 0;
|
||||||
|
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetCutsceneDispose(assetentry_t *entry) {
|
||||||
|
assertNotNull(entry, "Entry cannot be NULL");
|
||||||
|
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
assetCutsceneFreeParsed(&entry->data.cutscene);
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "asset/assetfile.h"
|
||||||
|
#include "rpg/cutscene/cutscene.h"
|
||||||
|
#include "rpg/cutscene/item/cutsceneitem.h"
|
||||||
|
#include "rpg/cutscene/item/json/cutscenejsonpool.h"
|
||||||
|
#include "yyjson.h"
|
||||||
|
|
||||||
|
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
|
||||||
|
|
||||||
|
typedef struct assetloading_s assetloading_t;
|
||||||
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void *nothing;
|
||||||
|
} assetcutsceneloaderinput_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
|
||||||
|
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
|
||||||
|
ASSET_CUTSCENE_LOADING_STATE_PARSE
|
||||||
|
} assetcutsceneloadingstate_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
assetfile_t file;
|
||||||
|
assetcutsceneloadingstate_t state;
|
||||||
|
uint8_t *buffer;
|
||||||
|
size_t size;
|
||||||
|
} assetcutsceneloaderloading_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
cutscene_t cutscene;
|
||||||
|
cutsceneitem_t *items;
|
||||||
|
uint8_t *pool;
|
||||||
|
yyjson_doc *doc;
|
||||||
|
} assetcutsceneoutput_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one cutscene item object into item, writing any pool-backed
|
||||||
|
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
|
||||||
|
* *poolOffset and advancing it. Every field/enum name is validated as
|
||||||
|
* real (untrusted, authored) file content - failures are real errors, not
|
||||||
|
* asserts. Dispatches to the matching type's Load callback (see
|
||||||
|
* CUTSCENE_ITEM_CALLBACKS in rpg/cutscene/item/cutsceneitem.h) - the
|
||||||
|
* per-type parsing logic itself lives there now, colocated with that
|
||||||
|
* type's runtime behavior.
|
||||||
|
*
|
||||||
|
* @param loading Loading information for the asset being loaded.
|
||||||
|
* @param itemObj The item's JSON object.
|
||||||
|
* @param item Destination item, already zeroed by the caller.
|
||||||
|
* @param pool Base pointer of the entry's pool allocation (may be NULL if
|
||||||
|
* assetCutsceneComputePoolSize returned 0).
|
||||||
|
* @param poolOffset In/out cursor into pool.
|
||||||
|
* @param index Index of this item within the cutscene, for error messages.
|
||||||
|
* @return Error code indicating success or failure of the parse.
|
||||||
|
*/
|
||||||
|
errorret_t assetCutsceneParseItem(
|
||||||
|
assetloading_t *loading,
|
||||||
|
yyjson_val *itemObj,
|
||||||
|
cutsceneitem_t *item,
|
||||||
|
uint8_t *pool,
|
||||||
|
size_t *poolOffset,
|
||||||
|
const uint8_t index
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
|
||||||
|
* and clears them - shared by both the normal disposer and every parse
|
||||||
|
* failure path's cleanup.
|
||||||
|
*
|
||||||
|
* @param out The output to free.
|
||||||
|
*/
|
||||||
|
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
|
||||||
|
* into the loading buffer so the sync phase can parse without blocking the
|
||||||
|
* main thread on I/O.
|
||||||
|
*
|
||||||
|
* @param loading Loading information for the asset being loaded.
|
||||||
|
* @return Error code indicating success or failure of the load operation.
|
||||||
|
*/
|
||||||
|
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous loader for cutscene assets. Parses the JSONC previously read
|
||||||
|
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
|
||||||
|
*
|
||||||
|
* @param loading Loading information for the asset being loaded.
|
||||||
|
* @return Error code indicating success or failure of the load operation.
|
||||||
|
*/
|
||||||
|
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposer for cutscene assets.
|
||||||
|
*
|
||||||
|
* @param entry Asset entry containing the cutscene data to dispose.
|
||||||
|
* @return Error code indicating success or failure of the dispose operation.
|
||||||
|
*/
|
||||||
|
errorret_t assetCutsceneDispose(assetentry_t *entry);
|
||||||
@@ -115,7 +115,8 @@ errorret_t assetModelLoaderSync(assetloading_t *loading) {
|
|||||||
assetUnlockEntry(jsonEntry);
|
assetUnlockEntry(jsonEntry);
|
||||||
|
|
||||||
// Lock and load the mesh sub-asset.
|
// Lock and load the mesh sub-asset.
|
||||||
assetentry_t *meshEntry = assetLock(meshName, ASSET_LOADER_TYPE_MESH, NULL);
|
assetentry_t *meshEntry =
|
||||||
|
assetLock(meshName, ASSET_LOADER_TYPE_MESH, NULL);
|
||||||
ret = assetRequireLoaded(meshEntry);
|
ret = assetRequireLoaded(meshEntry);
|
||||||
if(errorIsNotOk(ret)) {
|
if(errorIsNotOk(ret)) {
|
||||||
assetUnlockEntry(meshEntry);
|
assetUnlockEntry(meshEntry);
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ errorret_t assetLocaleLoaderAsync(assetloading_t *loading) {
|
|||||||
assetLoaderErrorChain(loading, assetFileOpen(&localeFile->file));
|
assetLoaderErrorChain(loading, assetFileOpen(&localeFile->file));
|
||||||
|
|
||||||
char_t buffer[1024];
|
char_t buffer[1024];
|
||||||
assetLoaderErrorChain(loading, assetLocaleGetString(
|
assetLoaderErrorChain(loading, assetLocaleGetHeader(
|
||||||
localeFile, "", 0, buffer, sizeof(buffer)
|
localeFile, buffer, sizeof(buffer)
|
||||||
));
|
));
|
||||||
assetLoaderErrorChain(loading, assetLocaleParseHeader(
|
assetLoaderErrorChain(loading, assetLocaleParseHeader(
|
||||||
localeFile, buffer, sizeof(buffer)
|
localeFile, buffer, sizeof(buffer)
|
||||||
@@ -71,6 +71,12 @@ errorret_t assetLocaleDispose(assetentry_t *entry) {
|
|||||||
assertIsMainThread("Must be called from the main thread.");
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
assetlocalefile_t *localeFile = &entry->data.locale;
|
assetlocalefile_t *localeFile = &entry->data.locale;
|
||||||
|
|
||||||
|
if(localeFile->cache != NULL) {
|
||||||
|
memoryFree(localeFile->cache);
|
||||||
|
localeFile->cache = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
errorChain(assetFileClose(&localeFile->file));
|
errorChain(assetFileClose(&localeFile->file));
|
||||||
return assetFileDispose(&localeFile->file);
|
return assetFileDispose(&localeFile->file);
|
||||||
}
|
}
|
||||||
@@ -470,7 +476,94 @@ errorret_t assetLocaleLineUnbuffer(
|
|||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
errorret_t assetLocaleGetString(
|
errorret_t assetLocaleCacheFind(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize,
|
||||||
|
bool_t *outHit
|
||||||
|
) {
|
||||||
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
|
assertNotNull(messageId, "Message ID cannot be NULL.");
|
||||||
|
assertNotNull(outHit, "outHit cannot be NULL.");
|
||||||
|
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
|
||||||
|
|
||||||
|
*outHit = false;
|
||||||
|
if(file->cache == NULL) errorOk();
|
||||||
|
|
||||||
|
assetlocalecache_t *cache = file->cache;
|
||||||
|
for(uint8_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) {
|
||||||
|
assetlocalecacheentry_t *entry = &cache->entries[i];
|
||||||
|
if(entry->messageId[0] == '\0') break;// unused tail - nothing further
|
||||||
|
if(entry->pluralCount != pluralCount) continue;
|
||||||
|
if(!stringEquals(entry->messageId, messageId)) continue;
|
||||||
|
|
||||||
|
// Move to the front of the LRU order (a no-op when already there).
|
||||||
|
if(i > 0) {
|
||||||
|
assetlocalecacheentry_t hit = *entry;
|
||||||
|
memoryMove(
|
||||||
|
&cache->entries[1], &cache->entries[0],
|
||||||
|
i * sizeof(assetlocalecacheentry_t)
|
||||||
|
);
|
||||||
|
cache->entries[0] = hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t len = strlen(cache->entries[0].value);
|
||||||
|
if(len >= stringBufferSize) errorThrow("String buffer overflow");
|
||||||
|
memoryCopy(stringBuffer, cache->entries[0].value, len + 1);
|
||||||
|
*outHit = true;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void assetLocaleCacheInsert(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
const char_t *value
|
||||||
|
) {
|
||||||
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
|
assertNotNull(messageId, "Message ID cannot be NULL.");
|
||||||
|
assertNotNull(value, "Value cannot be NULL.");
|
||||||
|
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
|
||||||
|
|
||||||
|
// Caching is a pure optimization - a message ID or value too long for a
|
||||||
|
// fixed cache slot is just never cached, not an error.
|
||||||
|
if(strlen(messageId) >= ASSET_LOCALE_CACHE_KEY_MAX) return;
|
||||||
|
if(strlen(value) >= ASSET_LOCALE_CACHE_VALUE_MAX) return;
|
||||||
|
|
||||||
|
if(file->cache == NULL) {
|
||||||
|
file->cache =
|
||||||
|
(assetlocalecache_t *)memoryAllocate(sizeof(assetlocalecache_t));
|
||||||
|
memoryZero(file->cache, sizeof(assetlocalecache_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift every entry down one slot, dropping the least-recently-used one
|
||||||
|
// off the end, to make room for the new entry at the front. This assumes
|
||||||
|
// messageId is never already present elsewhere in the cache, which holds
|
||||||
|
// as long as callers only insert after a confirmed assetLocaleCacheFind
|
||||||
|
// miss (a hit would have returned before reaching this point).
|
||||||
|
memoryMove(
|
||||||
|
&file->cache->entries[1], &file->cache->entries[0],
|
||||||
|
(ASSET_LOCALE_CACHE_COUNT - 1) * sizeof(assetlocalecacheentry_t)
|
||||||
|
);
|
||||||
|
|
||||||
|
assetlocalecacheentry_t *entry = &file->cache->entries[0];
|
||||||
|
stringCopy(entry->messageId, messageId, ASSET_LOCALE_CACHE_KEY_MAX);
|
||||||
|
entry->pluralCount = pluralCount;
|
||||||
|
stringCopy(entry->value, value, ASSET_LOCALE_CACHE_VALUE_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by assetLocaleGetString (external callers, messageId required
|
||||||
|
// non-empty) and assetLocaleGetHeader (messageId "", the PO format's own
|
||||||
|
// sentinel for its header entry - see assetLocaleGetHeader). Caching is
|
||||||
|
// skipped entirely for an empty messageId: assetLocaleCacheFind/Insert
|
||||||
|
// both reject one outright (same guard as assetLocaleGetString's own),
|
||||||
|
// and it's a one-shot boot-time read anyway, not worth caching.
|
||||||
|
errorret_t assetLocaleGetStringLookup(
|
||||||
assetlocalefile_t *file,
|
assetlocalefile_t *file,
|
||||||
const char_t *messageId,
|
const char_t *messageId,
|
||||||
const int32_t pluralCount,
|
const int32_t pluralCount,
|
||||||
@@ -482,6 +575,15 @@ errorret_t assetLocaleGetString(
|
|||||||
assertTrue(pluralCount >= 0, "Plural index cannot be negative.");
|
assertTrue(pluralCount >= 0, "Plural index cannot be negative.");
|
||||||
assertNotNull(stringBuffer, "String buffer cannot be NULL.");
|
assertNotNull(stringBuffer, "String buffer cannot be NULL.");
|
||||||
assertTrue(stringBufferSize > 0, "String buffer size must be > 0");
|
assertTrue(stringBufferSize > 0, "String buffer size must be > 0");
|
||||||
|
|
||||||
|
bool_t cacheHit = false;
|
||||||
|
if(messageId[0] != '\0') {
|
||||||
|
errorChain(assetLocaleCacheFind(
|
||||||
|
file, messageId, pluralCount, stringBuffer, stringBufferSize, &cacheHit
|
||||||
|
));
|
||||||
|
if(cacheHit) errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
assetfilelinereader_t reader;
|
assetfilelinereader_t reader;
|
||||||
|
|
||||||
bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false;
|
bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false;
|
||||||
@@ -625,9 +727,38 @@ errorret_t assetLocaleGetString(
|
|||||||
errorThrow("Failed to find msgstr for message ID: %s", messageId);
|
errorThrow("Failed to find msgstr for message ID: %s", messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(messageId[0] != '\0') {
|
||||||
|
assetLocaleCacheInsert(file, messageId, pluralCount, stringBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorret_t assetLocaleGetString(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize
|
||||||
|
) {
|
||||||
|
assertNotNull(messageId, "Message ID cannot be NULL.");
|
||||||
|
assertStrLenMin(messageId, 1, "Message ID cannot be empty.");
|
||||||
|
|
||||||
|
return assetLocaleGetStringLookup(
|
||||||
|
file, messageId, pluralCount, stringBuffer, stringBufferSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetLocaleGetHeader(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize
|
||||||
|
) {
|
||||||
|
return assetLocaleGetStringLookup(
|
||||||
|
file, "", 0, stringBuffer, stringBufferSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
errorret_t assetLocaleGetStringWithVA(
|
errorret_t assetLocaleGetStringWithVA(
|
||||||
assetlocalefile_t *file,
|
assetlocalefile_t *file,
|
||||||
const char_t *messageId,
|
const char_t *messageId,
|
||||||
|
|||||||
@@ -70,6 +70,41 @@ typedef struct {
|
|||||||
};
|
};
|
||||||
} assetlocalearg_t;
|
} assetlocalearg_t;
|
||||||
|
|
||||||
|
/** Number of recently resolved strings @ref assetlocalecache_t remembers. */
|
||||||
|
#define ASSET_LOCALE_CACHE_COUNT 128
|
||||||
|
|
||||||
|
/** Max length (excluding null terminator) of a cacheable message ID. */
|
||||||
|
#define ASSET_LOCALE_CACHE_KEY_MAX 64
|
||||||
|
|
||||||
|
/** Max length (excluding null terminator) of a cacheable resolved string. */
|
||||||
|
#define ASSET_LOCALE_CACHE_VALUE_MAX 256
|
||||||
|
|
||||||
|
/** One (messageId, pluralCount) -> resolved string cache slot. */
|
||||||
|
typedef struct {
|
||||||
|
char_t messageId[ASSET_LOCALE_CACHE_KEY_MAX];
|
||||||
|
int32_t pluralCount;
|
||||||
|
char_t value[ASSET_LOCALE_CACHE_VALUE_MAX];
|
||||||
|
} assetlocalecacheentry_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed-size move-to-front LRU cache of recently resolved locale strings.
|
||||||
|
* assetLocaleGetString() re-scans and re-decompresses the whole PO file on
|
||||||
|
* every miss, which is expensive to repeat for strings a screen fetches
|
||||||
|
* every time it opens (menu labels, etc) - this cache lets identical
|
||||||
|
* (messageId, pluralCount) lookups skip that entirely.
|
||||||
|
*
|
||||||
|
* Lazily allocated on the first cache insert (see assetlocalefile_t.cache)
|
||||||
|
* so locale entries that are never queried, or a file that's disposed
|
||||||
|
* before anything is cached, never pay for it.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
/**
|
||||||
|
* Entries ordered most-recently-used first. An entry with
|
||||||
|
* messageId[0] == '\0' (and every entry after it) is unused.
|
||||||
|
*/
|
||||||
|
assetlocalecacheentry_t entries[ASSET_LOCALE_CACHE_COUNT];
|
||||||
|
} assetlocalecache_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime state for an open locale file.
|
* Runtime state for an open locale file.
|
||||||
*
|
*
|
||||||
@@ -98,6 +133,16 @@ typedef struct {
|
|||||||
|
|
||||||
/** Form index used when no conditional clause matches. */
|
/** Form index used when no conditional clause matches. */
|
||||||
uint8_t pluralDefaultIndex;
|
uint8_t pluralDefaultIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently resolved string cache, or NULL if nothing has been cached yet.
|
||||||
|
* Heap-allocated rather than embedded because assetlocalefile_t lives
|
||||||
|
* inside the shared assetloaderoutput_t union alongside every other
|
||||||
|
* asset type - embedding a 128-entry cache there would size that union
|
||||||
|
* (and therefore every one of the ASSET_ENTRY_COUNT_MAX asset slots,
|
||||||
|
* regardless of what type of asset occupies it) up by the same amount.
|
||||||
|
*/
|
||||||
|
assetlocalecache_t *cache;
|
||||||
} assetlocalefile_t;
|
} assetlocalefile_t;
|
||||||
|
|
||||||
/** Convenience alias - the loaded output type of a locale asset entry. */
|
/** Convenience alias - the loaded output type of a locale asset entry. */
|
||||||
@@ -204,14 +249,92 @@ errorret_t assetLocaleLineUnbuffer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up a translated string by message ID from the open locale file.
|
* Searches a locale file's cache for a previously resolved
|
||||||
|
* (messageId, pluralCount) pair.
|
||||||
*
|
*
|
||||||
* Rewinds the file and scans from the beginning on every call. For plural
|
* On a hit, copies the cached value into `stringBuffer` (erroring if it
|
||||||
* entries (`msgid_plural`) the `pluralCount` is evaluated against the loaded
|
* doesn't fit, same contract as @ref assetLocaleGetString) and moves the
|
||||||
* plural rules to select the correct `msgstr[N]` form.
|
* entry to the front of the cache's move-to-front LRU order. On a miss,
|
||||||
|
* `stringBuffer` is left untouched.
|
||||||
|
*
|
||||||
|
* @param file Locale file whose cache to search. Its cache may be NULL
|
||||||
|
* (nothing cached yet), which is treated as a miss.
|
||||||
|
* @param messageId Message ID to look up.
|
||||||
|
* @param pluralCount Plural count the original lookup used.
|
||||||
|
* @param stringBuffer Destination buffer, filled only on a hit.
|
||||||
|
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
|
||||||
|
* @param outHit Set to true on a cache hit, false on a miss.
|
||||||
|
* @return OK on success (hit or miss), error if a hit's cached value does
|
||||||
|
* not fit `stringBuffer`.
|
||||||
|
*/
|
||||||
|
errorret_t assetLocaleCacheFind(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize,
|
||||||
|
bool_t *outHit
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a resolved (messageId, pluralCount) -> value string at the front
|
||||||
|
* of the cache's move-to-front LRU order, evicting the least-recently-used
|
||||||
|
* entry if the cache is already full. Lazily allocates the cache (see
|
||||||
|
* assetlocalefile_t.cache) on the first call.
|
||||||
|
*
|
||||||
|
* Caching is a pure optimization: if `messageId` or `value` is too long to
|
||||||
|
* fit the cache's fixed-size slots, this silently does nothing rather than
|
||||||
|
* erroring or growing the cache.
|
||||||
|
*
|
||||||
|
* @param file Locale file whose cache to insert into.
|
||||||
|
* @param messageId Message ID that was looked up.
|
||||||
|
* @param pluralCount Plural count the lookup was made with.
|
||||||
|
* @param value Resolved string to cache.
|
||||||
|
*/
|
||||||
|
void assetLocaleCacheInsert(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
const char_t *value
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared lookup core behind assetLocaleGetString and assetLocaleGetHeader:
|
||||||
|
* checks the file's cache first (see @ref assetLocaleCacheFind; skipped
|
||||||
|
* entirely for an empty messageId, since the cache rejects one), on a
|
||||||
|
* miss rewinds the file and scans from the beginning, then caches the
|
||||||
|
* result (see @ref assetLocaleCacheInsert, same empty-messageId skip)
|
||||||
|
* before returning. For plural entries (`msgid_plural`) the
|
||||||
|
* `pluralCount` is evaluated against the loaded plural rules to select
|
||||||
|
* the correct `msgstr[N]` form.
|
||||||
|
*
|
||||||
|
* Unlike assetLocaleGetString, messageId may be `""` here (see
|
||||||
|
* assetLocaleGetHeader) - this is the shared implementation both of
|
||||||
|
* them delegate to, not a public entry point of its own in practice.
|
||||||
*
|
*
|
||||||
* @param file Locale file to search. Must be open.
|
* @param file Locale file to search. Must be open.
|
||||||
* @param messageId PO message ID to find (`""` retrieves the header entry).
|
* @param messageId PO message ID to find, or `""` for the header entry.
|
||||||
|
* @param pluralCount Count used to select the plural form (ignored for
|
||||||
|
* singular entries).
|
||||||
|
* @param stringBuffer Buffer to receive the translated string.
|
||||||
|
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
|
||||||
|
* @return OK on success, error if the message ID is not found or I/O fails.
|
||||||
|
*/
|
||||||
|
errorret_t assetLocaleGetStringLookup(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
const char_t *messageId,
|
||||||
|
const int32_t pluralCount,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a translated string by message ID from the open locale file.
|
||||||
|
* See @ref assetLocaleGetStringLookup for the lookup/caching mechanics.
|
||||||
|
*
|
||||||
|
* @param file Locale file to search. Must be open.
|
||||||
|
* @param messageId PO message ID to find. Must be non-empty - use
|
||||||
|
* @ref assetLocaleGetHeader to read the header entry.
|
||||||
* @param pluralCount Count used to select the plural form (ignored for
|
* @param pluralCount Count used to select the plural form (ignored for
|
||||||
* singular entries).
|
* singular entries).
|
||||||
* @param stringBuffer Buffer to receive the translated string.
|
* @param stringBuffer Buffer to receive the translated string.
|
||||||
@@ -226,6 +349,27 @@ errorret_t assetLocaleGetString(
|
|||||||
const size_t stringBufferSize
|
const size_t stringBufferSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the PO file's header entry (the `msgid ""` block every PO file
|
||||||
|
* starts with, holding metadata like the plural-forms rule) - see @ref
|
||||||
|
* assetLocaleParseHeader, its caller. A dedicated function rather than
|
||||||
|
* calling assetLocaleGetString(file, "", ...) since messageId "" is a
|
||||||
|
* PO-format-internal sentinel, not a real (untrusted) caller-supplied
|
||||||
|
* message ID, so it doesn't go through assetLocaleGetString's normal
|
||||||
|
* non-empty validation.
|
||||||
|
*
|
||||||
|
* @param file Locale file to search. Must be open.
|
||||||
|
* @param stringBuffer Buffer to receive the raw header block.
|
||||||
|
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
|
||||||
|
* @return OK on success, error if the header entry is missing or I/O
|
||||||
|
* fails.
|
||||||
|
*/
|
||||||
|
errorret_t assetLocaleGetHeader(
|
||||||
|
assetlocalefile_t *file,
|
||||||
|
char_t *stringBuffer,
|
||||||
|
const size_t stringBufferSize
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up a translated string and formats it with a `printf`-style variadic
|
* Looks up a translated string and formats it with a `printf`-style variadic
|
||||||
* argument list.
|
* argument list.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
|
PUBLIC
|
||||||
|
assetmp3loader.c
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "assetmp3loader.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "asset/loader/assetloading.h"
|
||||||
|
#include "asset/loader/assetentry.h"
|
||||||
|
|
||||||
|
// How far into the file (after any ID3v2 tag) assetMp3ParseHeader() will
|
||||||
|
// search for the first valid MPEG frame sync. Real encoders write it
|
||||||
|
// within the first handful of bytes; this is a generous sane upper bound
|
||||||
|
// (matching assetwavloader.c's own approach to bounding a stack buffer)
|
||||||
|
// rather than a real expectation of needing that much.
|
||||||
|
#define ASSET_MP3_HEADER_SEARCH_MAX 8192
|
||||||
|
|
||||||
|
// MPEG-1 Layer III bitrates (kbps), indexed by the header's 4-bit bitrate
|
||||||
|
// index. Index 0 ("free format") and 15 (reserved) aren't supported.
|
||||||
|
static const uint16_t ASSET_MP3_BITRATE_MPEG1_L3[16] = {
|
||||||
|
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// MPEG-2/2.5 Layer III bitrates (kbps) - a different table from MPEG-1's.
|
||||||
|
static const uint16_t ASSET_MP3_BITRATE_MPEG2_L3[16] = {
|
||||||
|
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG1[4] = {
|
||||||
|
44100, 48000, 32000, 0
|
||||||
|
};
|
||||||
|
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG2[4] = {
|
||||||
|
22050, 24000, 16000, 0
|
||||||
|
};
|
||||||
|
static const uint32_t ASSET_MP3_SAMPLE_RATE_MPEG25[4] = {
|
||||||
|
11025, 12000, 8000, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
errorret_t assetMp3LoaderAsync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertNotMainThread("Async loader should not be on main thread.");
|
||||||
|
|
||||||
|
if(loading->loading.mp3.state != ASSET_MP3_LOADER_STATE_READ_HEADER) {
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
assetmp3file_t *mp3File = &loading->entry->data.mp3;
|
||||||
|
memoryZero(mp3File, sizeof(assetmp3file_t));
|
||||||
|
|
||||||
|
assetfile_t file;
|
||||||
|
assetLoaderErrorChain(loading, assetFileInit(
|
||||||
|
&file, loading->entry->name, NULL, NULL
|
||||||
|
));
|
||||||
|
assetLoaderErrorChain(loading, assetFileOpen(&file));
|
||||||
|
assetLoaderErrorChain(loading, assetMp3ParseHeader(&file, mp3File));
|
||||||
|
assetLoaderErrorChain(loading, assetFileClose(&file));
|
||||||
|
assetLoaderErrorChain(loading, assetFileDispose(&file));
|
||||||
|
|
||||||
|
loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_DONE;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetMp3LoaderSync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertTrue(loading->type == ASSET_LOADER_TYPE_MP3, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
switch(loading->loading.mp3.state) {
|
||||||
|
case ASSET_MP3_LOADER_STATE_INITIAL:
|
||||||
|
loading->loading.mp3.state = ASSET_MP3_LOADER_STATE_READ_HEADER;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
||||||
|
errorOk();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ASSET_MP3_LOADER_STATE_DONE:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetMp3Dispose(assetentry_t *entry) {
|
||||||
|
assertNotNull(entry, "Asset entry cannot be NULL");
|
||||||
|
assertTrue(entry->type == ASSET_LOADER_TYPE_MP3, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File) {
|
||||||
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
|
assertNotNull(mp3File, "Mp3 file cannot be NULL.");
|
||||||
|
|
||||||
|
memoryZero(mp3File, sizeof(assetmp3file_t));
|
||||||
|
|
||||||
|
// Skip a leading ID3v2 tag if present: "ID3" + version(2) + flags(1) + a
|
||||||
|
// 4-byte syncsafe (7 usable bits/byte) size covering everything after
|
||||||
|
// this 10-byte header.
|
||||||
|
uint8_t id3Header[10];
|
||||||
|
errorChain(assetFileRead(file, id3Header, sizeof(id3Header)));
|
||||||
|
|
||||||
|
size_t searchBase;
|
||||||
|
if(memoryCompare(id3Header, "ID3", 3) == 0) {
|
||||||
|
const uint32_t tagSize = (
|
||||||
|
((uint32_t) (id3Header[6] & 0x7F) << 21) |
|
||||||
|
((uint32_t) (id3Header[7] & 0x7F) << 14) |
|
||||||
|
((uint32_t) (id3Header[8] & 0x7F) << 7) |
|
||||||
|
((uint32_t) (id3Header[9] & 0x7F))
|
||||||
|
);
|
||||||
|
errorChain(assetFileRead(file, NULL, tagSize));
|
||||||
|
searchBase = sizeof(id3Header) + tagSize;
|
||||||
|
} else {
|
||||||
|
// No ID3v2 tag - rewind the probe bytes just consumed so the frame
|
||||||
|
// sync search below starts from the true beginning of the file.
|
||||||
|
errorChain(assetFileRewind(file));
|
||||||
|
searchBase = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t searchWindowSize = mathMin(
|
||||||
|
(size_t) ASSET_MP3_HEADER_SEARCH_MAX,
|
||||||
|
(size_t) file->size > searchBase ? (size_t) file->size - searchBase : 0
|
||||||
|
);
|
||||||
|
if(searchWindowSize < 4) {
|
||||||
|
errorThrow(
|
||||||
|
"MP3 file is too short to contain a frame header: %s", file->filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t *window = (uint8_t *) memoryAllocate(searchWindowSize);
|
||||||
|
errorret_t readRet = assetFileRead(file, window, searchWindowSize);
|
||||||
|
if(errorIsNotOk(readRet)) {
|
||||||
|
memoryFree(window);
|
||||||
|
errorChain(readRet);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool_t found = false;
|
||||||
|
size_t frameIndex = 0;
|
||||||
|
|
||||||
|
for(size_t i = 0; i + 4 <= searchWindowSize; i++) {
|
||||||
|
if(window[i] != 0xFF || (window[i + 1] & 0xE0) != 0xE0) continue;
|
||||||
|
|
||||||
|
const uint8_t versionBits = (window[i + 1] >> 3) & 0x3;
|
||||||
|
const uint8_t layerBits = (window[i + 1] >> 1) & 0x3;
|
||||||
|
// reserved version, or not Layer III
|
||||||
|
if(versionBits == 1 || layerBits != 1) continue;
|
||||||
|
|
||||||
|
const uint8_t bitrateIndex = (window[i + 2] >> 4) & 0xF;
|
||||||
|
const uint8_t sampleRateIndex = (window[i + 2] >> 2) & 0x3;
|
||||||
|
if(
|
||||||
|
bitrateIndex == 0 || bitrateIndex == 15 || sampleRateIndex == 3
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
const uint8_t channelMode = (window[i + 3] >> 6) & 0x3;
|
||||||
|
const bool_t isMpeg1 = versionBits == 3;
|
||||||
|
|
||||||
|
mp3File->bitrateKbps = isMpeg1
|
||||||
|
? ASSET_MP3_BITRATE_MPEG1_L3[bitrateIndex]
|
||||||
|
: ASSET_MP3_BITRATE_MPEG2_L3[bitrateIndex];
|
||||||
|
mp3File->sampleRate = isMpeg1
|
||||||
|
? ASSET_MP3_SAMPLE_RATE_MPEG1[sampleRateIndex]
|
||||||
|
: (versionBits == 2
|
||||||
|
? ASSET_MP3_SAMPLE_RATE_MPEG2[sampleRateIndex]
|
||||||
|
: ASSET_MP3_SAMPLE_RATE_MPEG25[sampleRateIndex]);
|
||||||
|
mp3File->samplesPerFrame = isMpeg1 ? 1152 : 576;
|
||||||
|
mp3File->channels = channelMode == 3 ? 1 : 2;
|
||||||
|
|
||||||
|
// A Xing/Info header, if present, sits right after the side info -
|
||||||
|
// whose size depends on MPEG version and channel mode - immediately
|
||||||
|
// following the 4-byte header (plus a 2-byte CRC, if the protection
|
||||||
|
// bit says one follows).
|
||||||
|
const bool_t hasCrc = (window[i + 1] & 0x1) == 0;
|
||||||
|
const size_t sideInfoSize = isMpeg1
|
||||||
|
? (mp3File->channels == 1 ? 17 : 32)
|
||||||
|
: (mp3File->channels == 1 ? 9 : 17);
|
||||||
|
const size_t xingOffset = i + 4 + (hasCrc ? 2 : 0) + sideInfoSize;
|
||||||
|
|
||||||
|
if(
|
||||||
|
xingOffset + 8 <= searchWindowSize &&
|
||||||
|
(
|
||||||
|
memoryCompare(window + xingOffset, "Xing", 4) == 0 ||
|
||||||
|
memoryCompare(window + xingOffset, "Info", 4) == 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const uint8_t *flagsBytes = window + xingOffset + 4;
|
||||||
|
const uint32_t flags = (
|
||||||
|
((uint32_t) flagsBytes[0] << 24) | ((uint32_t) flagsBytes[1] << 16) |
|
||||||
|
((uint32_t) flagsBytes[2] << 8) | (uint32_t) flagsBytes[3]
|
||||||
|
);
|
||||||
|
if((flags & 0x1) != 0 && xingOffset + 12 <= searchWindowSize) {
|
||||||
|
const uint8_t *framesBytes = window + xingOffset + 8;
|
||||||
|
const uint32_t xingFrameCount = (
|
||||||
|
((uint32_t) framesBytes[0] << 24) |
|
||||||
|
((uint32_t) framesBytes[1] << 16) |
|
||||||
|
((uint32_t) framesBytes[2] << 8) |
|
||||||
|
(uint32_t) framesBytes[3]
|
||||||
|
);
|
||||||
|
// The Xing/Info header's frame count includes the header's own
|
||||||
|
// frame (this one, at `i`) - which libmad (and sceMp3) will
|
||||||
|
// attempt to decode like any other, but which doesn't contribute a
|
||||||
|
// real decodable frame of audio the way every other one does.
|
||||||
|
// Confirmed against a real LAME-encoded file: its Xing header
|
||||||
|
// declared 40 frames, but decoding actually produced exactly 39
|
||||||
|
// frames' worth of real PCM - without this -1, playback would
|
||||||
|
// permanently stall one frame short of the declared total (audible
|
||||||
|
// as "never loops"), since real decode output can never catch up
|
||||||
|
// to an overstated total.
|
||||||
|
if(xingFrameCount > 0) {
|
||||||
|
mp3File->totalFrames =
|
||||||
|
(size_t) (xingFrameCount - 1) * mp3File->samplesPerFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frameIndex = i;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryFree(window);
|
||||||
|
|
||||||
|
if(!found) {
|
||||||
|
errorThrow(
|
||||||
|
"MP3 file has no valid MPEG-1/2/2.5 Layer III frame within the first "
|
||||||
|
"%u bytes: %s",
|
||||||
|
(uint32_t) searchWindowSize, file->filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3File->dataOffset = searchBase + frameIndex;
|
||||||
|
mp3File->dataSize = (size_t) file->size - mp3File->dataOffset;
|
||||||
|
|
||||||
|
// No Xing/Info header found - estimate assuming constant bitrate (the
|
||||||
|
// common case when one's genuinely absent; a real VBR file without one
|
||||||
|
// is rare and will just get an approximate duration/loop point instead
|
||||||
|
// of an exact one).
|
||||||
|
if(mp3File->totalFrames == 0) {
|
||||||
|
const float_t durationSeconds = (
|
||||||
|
((float_t) mp3File->dataSize * 8.0f) /
|
||||||
|
((float_t) mp3File->bitrateKbps * 1000.0f)
|
||||||
|
);
|
||||||
|
mp3File->totalFrames =
|
||||||
|
(size_t) (durationSeconds * (float_t) mp3File->sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "error/error.h"
|
||||||
|
#include "asset/assetfile.h"
|
||||||
|
|
||||||
|
typedef struct assetloading_s assetloading_t;
|
||||||
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
|
typedef struct { void *nothing; } assetmp3loaderinput_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
ASSET_MP3_LOADER_STATE_INITIAL,
|
||||||
|
ASSET_MP3_LOADER_STATE_READ_HEADER,
|
||||||
|
ASSET_MP3_LOADER_STATE_DONE
|
||||||
|
} assetmp3loaderstate_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
assetmp3loaderstate_t state;
|
||||||
|
} assetmp3loaderloading_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed metadata for an MP3 asset - deliberately just enough to configure
|
||||||
|
* a decoder and know the clip's total length, not anything about its
|
||||||
|
* actual compressed content (which is read/decoded on demand at playback
|
||||||
|
* time - see audiostreammp3.c). Only MPEG-1/2/2.5 Layer III ("MP3" in the
|
||||||
|
* everyday sense) is supported; Layers I/II are rejected.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
uint32_t sampleRate;
|
||||||
|
uint8_t channels;
|
||||||
|
|
||||||
|
// 1152 for MPEG-1, 576 for MPEG-2/2.5 - how many PCM samples (per
|
||||||
|
// channel) one compressed MPEG frame decodes to.
|
||||||
|
uint16_t samplesPerFrame;
|
||||||
|
|
||||||
|
// The first frame's bitrate, in kbps. Used only as a fallback duration
|
||||||
|
// estimate (assuming CBR) when no Xing/Info header is found - see
|
||||||
|
// assetMp3ParseHeader()'s own comment.
|
||||||
|
uint32_t bitrateKbps;
|
||||||
|
|
||||||
|
// Byte offset (from the start of the file) of the first real MPEG
|
||||||
|
// frame, i.e. right after any leading ID3v2 tag.
|
||||||
|
size_t dataOffset;
|
||||||
|
|
||||||
|
// Bytes of compressed MPEG data, from dataOffset to the end of the file.
|
||||||
|
size_t dataSize;
|
||||||
|
|
||||||
|
// Decoded PCM frame count for the whole clip - exact if a Xing/Info
|
||||||
|
// header was found (the common case for anything encoded with a modern
|
||||||
|
// tool), otherwise estimated from bitrateKbps/dataSize assuming CBR (see
|
||||||
|
// assetMp3ParseHeader()'s own comment) - unlike assetwavfile_t.dataSize,
|
||||||
|
// MP3 has no header field that gives this directly.
|
||||||
|
size_t totalFrames;
|
||||||
|
} assetmp3file_t;
|
||||||
|
|
||||||
|
typedef assetmp3file_t assetmp3output_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async (background-thread) half of the MP3 loader - opens the asset file,
|
||||||
|
* parses its header (see assetMp3ParseHeader()), and closes it again. Never
|
||||||
|
* buffers the compressed MPEG data itself; that's read on demand at
|
||||||
|
* playback time by audiostreammp3.c via its own independent file handle.
|
||||||
|
*
|
||||||
|
* @param loading The asset loading slot.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t assetMp3LoaderAsync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync (main-thread) half of the MP3 loader - a simple state machine that
|
||||||
|
* schedules the async header parse and marks the entry loaded once it's
|
||||||
|
* done, mirroring assetWavLoaderSync()'s shape exactly.
|
||||||
|
*
|
||||||
|
* @param loading The asset loading slot.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t assetMp3LoaderSync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes an MP3 asset entry. A no-op - an assetmp3file_t owns no heap
|
||||||
|
* allocations, and playback streams open their own independent file
|
||||||
|
* handles (see audiostreammp3.c).
|
||||||
|
*
|
||||||
|
* @param entry The asset entry to dispose.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t assetMp3Dispose(assetentry_t *entry);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an MP3 file's metadata from an already-opened asset file
|
||||||
|
* positioned at its very start: skips a leading ID3v2 tag if present,
|
||||||
|
* scans forward for the first valid MPEG-1/2/2.5 Layer III frame header
|
||||||
|
* (rejecting anything else - other layers, or no valid frame found within
|
||||||
|
* a sane search window), and checks that frame for a Xing/Info header
|
||||||
|
* (found in the vast majority of real-world MP3s, VBR or CBR) to get an
|
||||||
|
* exact total-frame count; falls back to a CBR estimate from the first
|
||||||
|
* frame's bitrate and the file's remaining size if absent.
|
||||||
|
*
|
||||||
|
* Never reads more than a bounded lookahead window into memory - the bulk
|
||||||
|
* of the file (the actual compressed audio, following the parsed frame's
|
||||||
|
* position) is left untouched, exactly like assetWavParseHeader().
|
||||||
|
*
|
||||||
|
* @param file Asset file to parse, positioned at offset 0.
|
||||||
|
* @param mp3File Filled with the parsed metadata on success.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t assetMp3ParseHeader(assetfile_t *file, assetmp3file_t *mp3File);
|
||||||
@@ -6,6 +6,5 @@
|
|||||||
# Sources
|
# Sources
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
PUBLIC
|
PUBLIC
|
||||||
savevita.c
|
assetwavloader.c
|
||||||
savestreamvita.c
|
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "assetwavloader.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/endian.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "asset/loader/assetloading.h"
|
||||||
|
#include "asset/loader/assetentry.h"
|
||||||
|
|
||||||
|
// "RIFF" + chunkSize + "WAVE", before any sub-chunks begin.
|
||||||
|
#define ASSET_WAV_RIFF_HEADER_SIZE 12
|
||||||
|
|
||||||
|
// A sub-chunk header is a 4-byte id followed by a 4-byte little-endian size.
|
||||||
|
#define ASSET_WAV_CHUNK_HEADER_SIZE 8
|
||||||
|
|
||||||
|
#define ASSET_WAV_FMT_CHUNK_SIZE_MIN 16
|
||||||
|
// Sane upper bound for a stack buffer - real fmt chunks (PCM or otherwise)
|
||||||
|
// never come close to this; anything bigger is treated as malformed.
|
||||||
|
#define ASSET_WAV_FMT_CHUNK_SIZE_MAX 64
|
||||||
|
|
||||||
|
errorret_t assetWavLoaderAsync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertNotMainThread("Async loader should not be on main thread.");
|
||||||
|
|
||||||
|
if(loading->loading.wav.state != ASSET_WAV_LOADER_STATE_READ_HEADER) {
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
assetwavfile_t *wavFile = &loading->entry->data.wav;
|
||||||
|
memoryZero(wavFile, sizeof(assetwavfile_t));
|
||||||
|
|
||||||
|
assetfile_t file;
|
||||||
|
assetLoaderErrorChain(loading, assetFileInit(
|
||||||
|
&file, loading->entry->name, NULL, NULL
|
||||||
|
));
|
||||||
|
assetLoaderErrorChain(loading, assetFileOpen(&file));
|
||||||
|
assetLoaderErrorChain(loading, assetWavParseHeader(&file, wavFile));
|
||||||
|
assetLoaderErrorChain(loading, assetFileClose(&file));
|
||||||
|
assetLoaderErrorChain(loading, assetFileDispose(&file));
|
||||||
|
|
||||||
|
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_DONE;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetWavLoaderSync(assetloading_t *loading) {
|
||||||
|
assertNotNull(loading, "Loading cannot be NULL");
|
||||||
|
assertTrue(loading->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
switch(loading->loading.wav.state) {
|
||||||
|
case ASSET_WAV_LOADER_STATE_INITIAL:
|
||||||
|
loading->loading.wav.state = ASSET_WAV_LOADER_STATE_READ_HEADER;
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
||||||
|
errorOk();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ASSET_WAV_LOADER_STATE_DONE:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetWavDispose(assetentry_t *entry) {
|
||||||
|
assertNotNull(entry, "Asset entry cannot be NULL");
|
||||||
|
assertTrue(entry->type == ASSET_LOADER_TYPE_WAV, "Invalid type.");
|
||||||
|
assertIsMainThread("Must be called from the main thread.");
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t assetWavParseHeader(
|
||||||
|
assetfile_t *file,
|
||||||
|
assetwavfile_t *wavFile
|
||||||
|
) {
|
||||||
|
assertNotNull(file, "Asset file cannot be NULL.");
|
||||||
|
assertNotNull(wavFile, "Wav file cannot be NULL.");
|
||||||
|
|
||||||
|
uint8_t riffHeader[ASSET_WAV_RIFF_HEADER_SIZE];
|
||||||
|
errorChain(assetFileRead(file, riffHeader, sizeof(riffHeader)));
|
||||||
|
|
||||||
|
if(memoryCompare(riffHeader, "RIFF", 4) != 0) {
|
||||||
|
errorThrow("WAV file has an invalid RIFF header: %s", file->filename);
|
||||||
|
}
|
||||||
|
if(memoryCompare(riffHeader + 8, "WAVE", 4) != 0) {
|
||||||
|
errorThrow("WAV file has an invalid WAVE header: %s", file->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool_t foundFormat = false;
|
||||||
|
bool_t foundData = false;
|
||||||
|
uint16_t audioFormat = 0;
|
||||||
|
|
||||||
|
// Walk the chunk list, reading only chunk headers (and the small "fmt "
|
||||||
|
// body) - every other chunk, "data"'s sample bytes included, is skipped
|
||||||
|
// via a NULL-buffer read rather than buffered into memory. Stops as soon
|
||||||
|
// as "data" is found; nothing after it matters here.
|
||||||
|
while(!foundData && (size_t) file->position < (size_t) file->size) {
|
||||||
|
uint8_t chunkHeader[ASSET_WAV_CHUNK_HEADER_SIZE];
|
||||||
|
errorChain(assetFileRead(file, chunkHeader, sizeof(chunkHeader)));
|
||||||
|
|
||||||
|
uint32_t chunkSizeLE;
|
||||||
|
memoryCopy(&chunkSizeLE, chunkHeader + 4, sizeof(chunkSizeLE));
|
||||||
|
const uint32_t chunkSize = endianLittleToHost32(chunkSizeLE);
|
||||||
|
|
||||||
|
// Chunks are padded to an even total size - the pad byte (if any)
|
||||||
|
// isn't included in chunkSize but still needs to be skipped over.
|
||||||
|
const uint32_t chunkSizePadded = chunkSize + (chunkSize % 2);
|
||||||
|
|
||||||
|
if(memoryCompare(chunkHeader, "fmt ", 4) == 0) {
|
||||||
|
if(
|
||||||
|
chunkSize < ASSET_WAV_FMT_CHUNK_SIZE_MIN ||
|
||||||
|
chunkSize > ASSET_WAV_FMT_CHUNK_SIZE_MAX
|
||||||
|
) {
|
||||||
|
errorThrow("WAV 'fmt ' chunk has an unsupported size: %u", chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t fmtBuffer[ASSET_WAV_FMT_CHUNK_SIZE_MAX];
|
||||||
|
errorChain(assetFileRead(file, fmtBuffer, chunkSize));
|
||||||
|
if(chunkSize % 2 != 0) {
|
||||||
|
errorChain(assetFileRead(file, NULL, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t u16;
|
||||||
|
uint32_t u32;
|
||||||
|
|
||||||
|
memoryCopy(&u16, fmtBuffer + 0, sizeof(u16));
|
||||||
|
audioFormat = endianLittleToHost16(u16);
|
||||||
|
|
||||||
|
memoryCopy(&u16, fmtBuffer + 2, sizeof(u16));
|
||||||
|
wavFile->channels = (uint8_t) endianLittleToHost16(u16);
|
||||||
|
|
||||||
|
memoryCopy(&u32, fmtBuffer + 4, sizeof(u32));
|
||||||
|
wavFile->sampleRate = endianLittleToHost32(u32);
|
||||||
|
|
||||||
|
memoryCopy(&u16, fmtBuffer + 14, sizeof(u16));
|
||||||
|
wavFile->bitsPerSample = (uint8_t) endianLittleToHost16(u16);
|
||||||
|
|
||||||
|
foundFormat = true;
|
||||||
|
} else if(memoryCompare(chunkHeader, "data", 4) == 0) {
|
||||||
|
wavFile->dataOffset = (size_t) file->position;
|
||||||
|
wavFile->dataSize = chunkSize;
|
||||||
|
foundData = true;
|
||||||
|
} else {
|
||||||
|
errorChain(assetFileRead(file, NULL, chunkSizePadded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!foundFormat) {
|
||||||
|
errorThrow("WAV file is missing its 'fmt ' chunk: %s", file->filename);
|
||||||
|
}
|
||||||
|
if(!foundData) {
|
||||||
|
errorThrow("WAV file is missing its 'data' chunk: %s", file->filename);
|
||||||
|
}
|
||||||
|
if(audioFormat != 1) {
|
||||||
|
errorThrow(
|
||||||
|
"Unsupported WAV audio format: %u (only PCM is supported): %s",
|
||||||
|
audioFormat, file->filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(wavFile->bitsPerSample != 16 && wavFile->bitsPerSample != 24) {
|
||||||
|
errorThrow(
|
||||||
|
"Unsupported WAV bits per sample: %u (only 16/24-bit is supported): %s",
|
||||||
|
wavFile->bitsPerSample, file->filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(wavFile->channels == 0) {
|
||||||
|
errorThrow("WAV file declares 0 channels: %s", file->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "asset/assetfile.h"
|
||||||
|
|
||||||
|
typedef struct assetloading_s assetloading_t;
|
||||||
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
|
/** Input passed to the wav loader - currently unused. */
|
||||||
|
typedef struct { void *nothing; } assetwavloaderinput_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
ASSET_WAV_LOADER_STATE_INITIAL,
|
||||||
|
ASSET_WAV_LOADER_STATE_READ_HEADER,
|
||||||
|
ASSET_WAV_LOADER_STATE_DONE
|
||||||
|
} assetwavloaderstate_t;
|
||||||
|
|
||||||
|
/** Per-slot scratch data used while the wav file is loading. */
|
||||||
|
typedef struct {
|
||||||
|
assetwavloaderstate_t state;
|
||||||
|
} assetwavloaderloading_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed metadata for a WAV asset - only the RIFF/WAVE/fmt/data chunk
|
||||||
|
* headers are ever read; the PCM sample data itself is never loaded here
|
||||||
|
* (see @ref assetWavParseHeader). Playback reads sample data directly
|
||||||
|
* from the archive on demand instead - see audiostreampcm.h.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
/** Sample rate of the PCM data, in Hz. */
|
||||||
|
uint32_t sampleRate;
|
||||||
|
|
||||||
|
/** Number of interleaved channels in the PCM data. */
|
||||||
|
uint8_t channels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bits per sample of the *source* file data - 16 or 24 (see
|
||||||
|
* assetWavParseHeader). Playback always reads 16-bit samples out via
|
||||||
|
* audioStreamPcmRead() regardless of this - a 24-bit source is
|
||||||
|
* truncated to 16-bit there, since none of this project's audio
|
||||||
|
* backends (PSP/Dolphin hardware, SDL2 on Linux) accept anything wider.
|
||||||
|
*/
|
||||||
|
uint8_t bitsPerSample;
|
||||||
|
|
||||||
|
/** Byte offset from the start of the file to the first PCM sample. */
|
||||||
|
size_t dataOffset;
|
||||||
|
|
||||||
|
/** Size of the PCM data, in bytes. */
|
||||||
|
size_t dataSize;
|
||||||
|
} assetwavfile_t;
|
||||||
|
|
||||||
|
/** Convenience alias - the loaded output type of a wav asset entry. */
|
||||||
|
typedef assetwavfile_t assetwavoutput_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous loader callback. Opens the WAV file and reads just enough of
|
||||||
|
* it to locate and parse the `fmt ` chunk and locate (not read) the `data`
|
||||||
|
* chunk - see @ref assetWavParseHeader. All I/O happens here so the main
|
||||||
|
* thread is not blocked. Sets entry state to `ASSET_ENTRY_STATE_PENDING_SYNC`
|
||||||
|
* on success or `ASSET_ENTRY_STATE_ERROR` on failure.
|
||||||
|
*
|
||||||
|
* @param loading The loading slot for this asset entry.
|
||||||
|
* @return OK on success, error otherwise.
|
||||||
|
*/
|
||||||
|
errorret_t assetWavLoaderAsync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous loader callback. Confirms the async phase completed and marks
|
||||||
|
* the entry as `ASSET_ENTRY_STATE_LOADED`.
|
||||||
|
*
|
||||||
|
* @param loading The loading slot for this asset entry.
|
||||||
|
* @return OK on success, error otherwise.
|
||||||
|
*/
|
||||||
|
errorret_t assetWavLoaderSync(assetloading_t *loading);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose callback. The wav asset owns no allocations of its own (its data
|
||||||
|
* chunk is read directly from the archive by whichever streams are playing
|
||||||
|
* it, each through their own handle - see audiostreampcm.h), so this is
|
||||||
|
* currently a no-op beyond the standard asserts.
|
||||||
|
*
|
||||||
|
* @param entry The asset entry to dispose.
|
||||||
|
* @return OK on success, error otherwise.
|
||||||
|
*/
|
||||||
|
errorret_t assetWavDispose(assetentry_t *entry);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a RIFF/WAVE file's chunk structure from an already-open asset
|
||||||
|
* file, reading only chunk headers (and the small `fmt ` chunk body) -
|
||||||
|
* every other chunk, including `data`'s actual sample bytes, is skipped
|
||||||
|
* over via `assetFileRead(file, NULL, size)` rather than read into memory,
|
||||||
|
* so this never buffers the (potentially large) PCM payload.
|
||||||
|
*
|
||||||
|
* Stops as soon as the `data` chunk header is found, recording its file
|
||||||
|
* offset and declared size in `wavFile` without reading any of its bytes -
|
||||||
|
* the file is left positioned at the start of the PCM data.
|
||||||
|
*
|
||||||
|
* Only PCM (audio format 1), 16- or 24-bit-per-sample WAV data is
|
||||||
|
* supported - see audiostreampcm.h's audioStreamPcmRead() for how a
|
||||||
|
* 24-bit source gets truncated to the 16-bit output every platform
|
||||||
|
* backend expects. Requires the `fmt ` chunk to appear before `data`, per
|
||||||
|
* the WAV spec's recommended ordering.
|
||||||
|
*
|
||||||
|
* @param file An open asset file, positioned at the start of the WAV data.
|
||||||
|
* @param wavFile Struct whose fields will be filled in.
|
||||||
|
* @return OK on success, error if the file is malformed or an unsupported
|
||||||
|
* format.
|
||||||
|
*/
|
||||||
|
errorret_t assetWavParseHeader(
|
||||||
|
assetfile_t *file,
|
||||||
|
assetwavfile_t *wavFile
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
# Sources
|
||||||
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
|
PUBLIC
|
||||||
|
audio.c
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subdirs
|
||||||
|
add_subdirectory(mixer)
|
||||||
|
add_subdirectory(stream)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audio.h"
|
||||||
|
#include "audio/mixer/audiomixer.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
|
||||||
|
audio_t AUDIO;
|
||||||
|
|
||||||
|
errorret_t audioInit() {
|
||||||
|
memoryZero(&AUDIO, sizeof(audio_t));
|
||||||
|
|
||||||
|
errorChain(audioPlatformInit());
|
||||||
|
errorChain(audioMixerInit());
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
audiostream_t * audioAquireStream(assetentry_t *asset) {
|
||||||
|
assertNotNull(asset, "Asset cannot be NULL.");
|
||||||
|
// Same check audioStreamInit() makes - see its own comment on why this
|
||||||
|
// is an assert (a programmer error, not untrusted data). Checking here
|
||||||
|
// too catches the mistake as early as possible, before any stream slot
|
||||||
|
// is handed out for it.
|
||||||
|
assertTrue(
|
||||||
|
audioStreamTypeForAssetType(asset->type) != AUDIO_STREAM_TYPE_NULL,
|
||||||
|
"Unsupported asset type for an audio stream."
|
||||||
|
);
|
||||||
|
|
||||||
|
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||||
|
audiostream_t *stream = &AUDIO.streams[i];
|
||||||
|
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioUpdateEarly() {
|
||||||
|
errorChain(audioPlatformUpdate());
|
||||||
|
errorChain(audioMixerUpdateEarly());
|
||||||
|
|
||||||
|
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||||
|
audiostream_t *stream = &AUDIO.streams[i];
|
||||||
|
errorChain(audioStreamUpdate(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioUpdateLate() {
|
||||||
|
errorChain(audioMixerUpdateLate());
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioDispose() {
|
||||||
|
for(uint8_t i = 0; i < AUDIO_STREAMS_MAX; i++) {
|
||||||
|
audiostream_t *stream = &AUDIO.streams[i];
|
||||||
|
errorChain(audioStreamDispose(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
errorChain(audioMixerDispose());
|
||||||
|
errorChain(audioPlatformDispose());
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "dusk.h"
|
||||||
|
#include "audio/stream/audiostream.h"
|
||||||
|
#include "audio/audioplatform.h"
|
||||||
|
|
||||||
|
#ifndef audioPlatformInit
|
||||||
|
#error "audioPlatformInit is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef audioPlatformUpdate
|
||||||
|
#error "audioPlatformUpdate is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef audioPlatformDispose
|
||||||
|
#error "audioPlatformDispose is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
audiostream_t streams[AUDIO_STREAMS_MAX];
|
||||||
|
} audio_t;
|
||||||
|
|
||||||
|
extern audio_t AUDIO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the audio subsystem.
|
||||||
|
*
|
||||||
|
* @return Error indicating success or failure of the operation.
|
||||||
|
*/
|
||||||
|
errorret_t audioInit();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aquires an available audio stream for playing the given asset. Can return
|
||||||
|
* NULL if there is no available stream slot.
|
||||||
|
*
|
||||||
|
* The returned stream is not yet configured to play `asset` - call
|
||||||
|
* audioStreamInit(stream, asset) next.
|
||||||
|
*
|
||||||
|
* @param asset The asset the caller intends to play - validated eagerly so
|
||||||
|
* an unsupported asset type is caught here rather than only
|
||||||
|
* once audioStreamInit() is called.
|
||||||
|
* @return Pointer to an available audio stream, or NULL if none are free.
|
||||||
|
*/
|
||||||
|
audiostream_t * audioAquireStream(assetentry_t *asset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the audio subsystem, updating every active stream. Should be
|
||||||
|
* called once per frame, before rendering - keeps stream buffering/decode
|
||||||
|
* work off the render thread's critical path (see duskmad's MP3 decode
|
||||||
|
* ring for why this matters).
|
||||||
|
*
|
||||||
|
* @return Error indicating success or failure of the operation.
|
||||||
|
*/
|
||||||
|
errorret_t audioUpdateEarly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the audio subsystem's end-of-frame work - currently just starting
|
||||||
|
* whatever the audio mixer queued this frame (see
|
||||||
|
* audioMixerUpdateLate()'s own comment). Should be called once per frame,
|
||||||
|
* after rendering.
|
||||||
|
*
|
||||||
|
* @return Error indicating success or failure of the operation.
|
||||||
|
*/
|
||||||
|
errorret_t audioUpdateLate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the audio subsystem, stopping and disposing every active stream.
|
||||||
|
*
|
||||||
|
* @return Error indicating success or failure of the operation.
|
||||||
|
*/
|
||||||
|
errorret_t audioDispose();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
# Sources
|
||||||
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
|
PUBLIC
|
||||||
|
audiomixer.c
|
||||||
|
audiomixerchannel.c
|
||||||
|
)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audiomixer.h"
|
||||||
|
#include "audio/stream/audiostream.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/string.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
|
||||||
|
audiomixer_t AUDIO_MIXER;
|
||||||
|
|
||||||
|
errorret_t audioMixerInit() {
|
||||||
|
memoryZero(&AUDIO_MIXER, sizeof(audiomixer_t));
|
||||||
|
|
||||||
|
audiomixerchannel_t channel = 0;
|
||||||
|
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
|
||||||
|
AUDIO_MIXER.channels[channel].fade = 1.0f;
|
||||||
|
// -1 means "no fade in progress" - see its own comment; zero-init
|
||||||
|
// would otherwise look like an active fade at time 0.
|
||||||
|
AUDIO_MIXER.channels[channel].fadeTime = -1.0f;
|
||||||
|
channel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioMixerUpdateEarly() {
|
||||||
|
audiomixerchannel_t channel = 0;
|
||||||
|
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
|
||||||
|
// One channel's error (e.g. a failed asset load) must not stop every
|
||||||
|
// other channel from being updated this frame - caught and logged
|
||||||
|
// rather than propagated, same as audioStream*IsFinished()'s own
|
||||||
|
// per-stream error handling.
|
||||||
|
errorret_t ret = audioMixerChannelUpdateEarly(
|
||||||
|
channel, &AUDIO_MIXER.channels[channel]
|
||||||
|
);
|
||||||
|
if(errorIsNotOk(ret)) errorCatch(errorPrint(ret));
|
||||||
|
channel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioMixerUpdateLate() {
|
||||||
|
audiomixerchannel_t channel = 0;
|
||||||
|
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
|
||||||
|
errorret_t ret =
|
||||||
|
audioMixerChannelUpdateLate(&AUDIO_MIXER.channels[channel]);
|
||||||
|
if(errorIsNotOk(ret)) errorCatch(errorPrint(ret));
|
||||||
|
channel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerPlay(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan
|
||||||
|
) {
|
||||||
|
audioMixerQueuePlay(file, channel, volume, pan, false, 0, -1.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerPlayLooped(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
) {
|
||||||
|
audioMixerQueuePlay(
|
||||||
|
file, channel, volume, pan, true, loopCount, loopStart, loopTo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerQueuePlay(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan,
|
||||||
|
const bool_t looping,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
) {
|
||||||
|
assertStrLenMin(file, 1, "File is required, to stop use audioMixerStop");
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
assertTrue(volume > 0.0f && volume <= 1.0f, "Volume is out of range");
|
||||||
|
assertTrue(
|
||||||
|
pan >= AUDIO_STREAM_LEFT && pan <= AUDIO_STREAM_RIGHT,
|
||||||
|
"Pan is out of range"
|
||||||
|
);
|
||||||
|
assertTrue(
|
||||||
|
!looping || loopStart < 0.0f || loopTo < loopStart,
|
||||||
|
"Loop points incorrect"
|
||||||
|
);
|
||||||
|
|
||||||
|
audiomixercommand_t *command = &AUDIO_MIXER.channels[channel].pending;
|
||||||
|
|
||||||
|
command->command = AUDIO_MIXER_COMMAND_PLAY;
|
||||||
|
stringCopy(command->file, file, AUDIO_PATH_MAX);
|
||||||
|
command->volume = volume;
|
||||||
|
command->pan = pan;
|
||||||
|
command->looping = looping;
|
||||||
|
command->loopCount = loopCount;
|
||||||
|
command->loopStart = loopStart;
|
||||||
|
command->loopTo = loopTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerStop(const audiomixerchannel_t channel) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
|
||||||
|
AUDIO_MIXER.channels[channel].pending.command = AUDIO_MIXER_COMMAND_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerPause(const audiomixerchannel_t channel) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
|
||||||
|
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
|
||||||
|
if(stream != NULL) audioStreamPause(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerResume(const audiomixerchannel_t channel) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
|
||||||
|
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
|
||||||
|
if(stream != NULL) audioStreamPlay(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerSetPan(const audiomixerchannel_t channel, const float_t pan) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
assertTrue(
|
||||||
|
pan >= AUDIO_STREAM_LEFT && pan <= AUDIO_STREAM_RIGHT,
|
||||||
|
"Pan is out of range"
|
||||||
|
);
|
||||||
|
|
||||||
|
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
|
||||||
|
if(stream != NULL) audioStreamSetDirectionality(stream, pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerSetLoop(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const bool_t looping,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
assertTrue(
|
||||||
|
!looping || loopStart < 0.0f || loopTo < loopStart,
|
||||||
|
"Loop points incorrect"
|
||||||
|
);
|
||||||
|
|
||||||
|
audiostream_t *stream = AUDIO_MIXER.channels[channel].stream;
|
||||||
|
if(stream == NULL) return;
|
||||||
|
|
||||||
|
audioStreamSetLooping(stream, looping);
|
||||||
|
if(looping) {
|
||||||
|
audioStreamSetLoopPoints(stream, loopStart, loopTo);
|
||||||
|
audioStreamSetLoopLimit(stream, loopCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerFadeTo(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t from,
|
||||||
|
const float_t to,
|
||||||
|
const float_t duration,
|
||||||
|
const easingtype_t easing
|
||||||
|
) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
|
||||||
|
audioMixerChannelFadeTo(
|
||||||
|
&AUDIO_MIXER.channels[channel], from, to, duration, easing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool_t audioMixerIsFading(const audiomixerchannel_t channel) {
|
||||||
|
assertTrue(channel < AUDIO_MIXER_CHANNEL_COUNT, "Channel is out of range");
|
||||||
|
|
||||||
|
return audioMixerChannelIsFading(&AUDIO_MIXER.channels[channel]);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioMixerDispose() {
|
||||||
|
audiomixerchannel_t channel = 0;
|
||||||
|
while(channel < AUDIO_MIXER_CHANNEL_COUNT) {
|
||||||
|
errorChain(audioMixerChannelStopStream(&AUDIO_MIXER.channels[channel]));
|
||||||
|
channel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "audio/mixer/audiomixerchannel.h"
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
audiomixerchannelstate_t channels[AUDIO_MIXER_CHANNEL_COUNT];
|
||||||
|
} audiomixer_t;
|
||||||
|
|
||||||
|
extern audiomixer_t AUDIO_MIXER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the audio mixer.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerInit();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the audio mixer, called before rendering (the start of the
|
||||||
|
* frame) - see audioMixerChannelUpdateEarly()'s own comment, run for every
|
||||||
|
* channel.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerUpdateEarly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the audio mixer, called at the end of each frame - see
|
||||||
|
* audioMixerChannelUpdateLate()'s own comment, run for every channel.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerUpdateLate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as audioMixerPlayLooped() but with no looping.
|
||||||
|
*
|
||||||
|
* @param file File to be played.
|
||||||
|
* @param channel Channel you want to play this sound on.
|
||||||
|
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
|
||||||
|
* AUDIO_STREAM_RIGHT.
|
||||||
|
*/
|
||||||
|
void audioMixerPlay(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a sound to be played on the next audio mixer update, which occurs at
|
||||||
|
* the end of each frame.
|
||||||
|
*
|
||||||
|
* Mixer channels can only hold one voice (audio stream) at a time. If you try
|
||||||
|
* to queue multiple sounds on the same channel only the last one will be played
|
||||||
|
* and will stop any actively playing on that channel currently.
|
||||||
|
*
|
||||||
|
* Volume is also mixed based on the channel, so supplying volume 1.0 is then
|
||||||
|
* multiplied by the channel's volume, e.g. 0.5*1.0 = 0.5.
|
||||||
|
*
|
||||||
|
* @param file File to be played.
|
||||||
|
* @param channel Channel you want to play this sound on.
|
||||||
|
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
|
||||||
|
* AUDIO_STREAM_RIGHT.
|
||||||
|
* @param loopCount How many times to loop the sound, 0 for infinite looping.
|
||||||
|
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
|
||||||
|
* the whole stream (the default).
|
||||||
|
* @param loopTo Where the loop segment starts, in seconds.
|
||||||
|
*/
|
||||||
|
void audioMixerPlayLooped(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared implementation behind audioMixerPlay()/audioMixerPlayLooped() -
|
||||||
|
* queues an AUDIO_MIXER_COMMAND_PLAY for the given channel, to be loaded at
|
||||||
|
* the end of this frame and started at the start of the next (see
|
||||||
|
* audioMixerUpdateLate()/audioMixerUpdateEarly()).
|
||||||
|
*
|
||||||
|
* @param file File to be played.
|
||||||
|
* @param channel Channel you want to play this sound on.
|
||||||
|
* @param volume Volume of the sound, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param pan Stereo panning of the sound, from AUDIO_STREAM_LEFT to
|
||||||
|
* AUDIO_STREAM_RIGHT.
|
||||||
|
* @param looping Whether the stream should loop once it finishes.
|
||||||
|
* @param loopCount How many times to loop the sound, 0 for infinite looping.
|
||||||
|
* Ignored unless looping is true.
|
||||||
|
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
|
||||||
|
* the whole stream. Ignored unless looping is true.
|
||||||
|
* @param loopTo Where the loop segment starts, in seconds. Ignored unless
|
||||||
|
* looping is true.
|
||||||
|
*/
|
||||||
|
void audioMixerQueuePlay(
|
||||||
|
const char_t *file,
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t volume,
|
||||||
|
const float_t pan,
|
||||||
|
const bool_t looping,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops playing sound on the given channel.
|
||||||
|
*
|
||||||
|
* @param channel Channel to stop.
|
||||||
|
*/
|
||||||
|
void audioMixerStop(const audiomixerchannel_t channel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses the given channel's currently playing stream, if any - a safe
|
||||||
|
* no-op if the channel isn't playing anything. Playback position is
|
||||||
|
* retained, so audioMixerResume() continues from the same point.
|
||||||
|
*
|
||||||
|
* @param channel Channel to pause.
|
||||||
|
*/
|
||||||
|
void audioMixerPause(const audiomixerchannel_t channel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the given channel's currently paused stream, if any - a safe
|
||||||
|
* no-op if the channel isn't playing anything.
|
||||||
|
*
|
||||||
|
* @param channel Channel to resume.
|
||||||
|
*/
|
||||||
|
void audioMixerResume(const audiomixerchannel_t channel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the stereo panning of the given channel's currently playing stream,
|
||||||
|
* if any - a safe no-op if the channel isn't playing anything. Only
|
||||||
|
* affects what's playing right now; a later audioMixerPlay()/
|
||||||
|
* audioMixerPlayLooped() call on this channel uses its own `pan` argument
|
||||||
|
* instead.
|
||||||
|
*
|
||||||
|
* @param channel Channel to update.
|
||||||
|
* @param pan Stereo panning, from AUDIO_STREAM_LEFT to AUDIO_STREAM_RIGHT.
|
||||||
|
*/
|
||||||
|
void audioMixerSetPan(const audiomixerchannel_t channel, const float_t pan);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the looping behaviour of the given channel's currently playing
|
||||||
|
* stream, if any - a safe no-op if the channel isn't playing anything.
|
||||||
|
* Only affects what's playing right now; a later audioMixerPlay()/
|
||||||
|
* audioMixerPlayLooped() call on this channel uses its own loop arguments
|
||||||
|
* instead.
|
||||||
|
*
|
||||||
|
* @param channel Channel to update.
|
||||||
|
* @param looping Whether the stream should loop.
|
||||||
|
* @param loopCount How many times to loop, 0 for infinite. Ignored unless
|
||||||
|
* looping is true.
|
||||||
|
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
|
||||||
|
* the whole stream. Ignored unless looping is true.
|
||||||
|
* @param loopTo Where the loop segment starts, in seconds. Ignored unless
|
||||||
|
* looping is true.
|
||||||
|
*/
|
||||||
|
void audioMixerSetLoop(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const bool_t looping,
|
||||||
|
const uint8_t loopCount,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts fading the given channel from `from` to `to` over `duration`
|
||||||
|
* seconds, eased by `easing` - see audioMixerChannelFadeTo()'s own
|
||||||
|
* comment. Continues running (advanced every audioMixerUpdateEarly()) even
|
||||||
|
* if the channel isn't currently playing anything.
|
||||||
|
*
|
||||||
|
* @param channel Channel to fade.
|
||||||
|
* @param from Starting fade value, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param to Ending fade value, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param duration How long the fade takes, in seconds.
|
||||||
|
* @param easing Easing curve to apply to the fade's progress.
|
||||||
|
*/
|
||||||
|
void audioMixerFadeTo(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
const float_t from,
|
||||||
|
const float_t to,
|
||||||
|
const float_t duration,
|
||||||
|
const easingtype_t easing
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given channel currently has a fade in progress (started via
|
||||||
|
* audioMixerFadeTo() and not yet finished).
|
||||||
|
*
|
||||||
|
* @param channel Channel to check.
|
||||||
|
* @return true if a fade is still in progress.
|
||||||
|
*/
|
||||||
|
bool_t audioMixerIsFading(const audiomixerchannel_t channel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the audio mixer.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerDispose();
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audiomixerchannel.h"
|
||||||
|
#include "asset/asset.h"
|
||||||
|
#include "audio/audio.h"
|
||||||
|
#include "audio/stream/audiostream.h"
|
||||||
|
#include "audio/stream/audiostreamtype.h"
|
||||||
|
#include "save/save.h"
|
||||||
|
#include "time/time.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
|
||||||
|
errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
if(state->stream == NULL) errorOk();
|
||||||
|
|
||||||
|
errorChain(audioStreamDispose(state->stream));
|
||||||
|
state->stream = NULL;
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerChannelFadeTo(
|
||||||
|
audiomixerchannelstate_t *state,
|
||||||
|
const float_t from,
|
||||||
|
const float_t to,
|
||||||
|
const float_t duration,
|
||||||
|
const easingtype_t easing
|
||||||
|
) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
state->fade = from;
|
||||||
|
state->fadeFrom = from;
|
||||||
|
state->fadeTo = to;
|
||||||
|
state->fadeDuration = duration;
|
||||||
|
state->fadeEasing = easing;
|
||||||
|
// A non-positive duration finishes instantly - marked inactive right
|
||||||
|
// away (fadeTime < 0) rather than left at 0 to progress next frame,
|
||||||
|
// since there's nothing left to progress.
|
||||||
|
if(duration <= 0.0f) {
|
||||||
|
state->fade = to;
|
||||||
|
state->fadeTime = -1.0f;
|
||||||
|
} else {
|
||||||
|
state->fadeTime = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool_t audioMixerChannelIsFading(const audiomixerchannelstate_t *state) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
return state->fadeTime >= 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerChannelApplyVolume(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
audiomixerchannelstate_t *state
|
||||||
|
) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
if(state->stream == NULL) return;
|
||||||
|
|
||||||
|
// Advances any in-progress fade before it's read below - see
|
||||||
|
// audioMixerChannelFadeTo()'s own comment on fadeTime's meaning.
|
||||||
|
if(state->fadeTime >= 0.0f) {
|
||||||
|
state->fadeTime += TIME.delta;
|
||||||
|
const float_t t = mathClamp(
|
||||||
|
state->fadeTime / state->fadeDuration, 0.0f, 1.0f
|
||||||
|
);
|
||||||
|
state->fade = state->fadeFrom +
|
||||||
|
(state->fadeTo - state->fadeFrom) * easingApply(state->fadeEasing, t);
|
||||||
|
if(state->fadeTime >= state->fadeDuration) {
|
||||||
|
state->fade = state->fadeTo;
|
||||||
|
state->fadeTime = -1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// state->fade and both SAVE.settings volumes have no setter - written
|
||||||
|
// directly by callers - so this is the only place any of them ever gets
|
||||||
|
// validated, rather than at the point they're set.
|
||||||
|
assertTrue(
|
||||||
|
state->fade >= 0.0f && state->fade <= 1.0f,
|
||||||
|
"Channel fade is out of range."
|
||||||
|
);
|
||||||
|
assertTrue(
|
||||||
|
SAVE.settings.audioChannelVolume[channel] >= 0.0f &&
|
||||||
|
SAVE.settings.audioChannelVolume[channel] <= 1.0f,
|
||||||
|
"Channel volume is out of range."
|
||||||
|
);
|
||||||
|
assertTrue(
|
||||||
|
SAVE.settings.audioMasterVolume >= 0.0f &&
|
||||||
|
SAVE.settings.audioMasterVolume <= 1.0f,
|
||||||
|
"Master volume is out of range."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Final computed volume.
|
||||||
|
float_t volume = (
|
||||||
|
state->baseVolume *
|
||||||
|
state->fade *
|
||||||
|
SAVE.settings.audioChannelVolume[channel] *
|
||||||
|
SAVE.settings.audioMasterVolume
|
||||||
|
);
|
||||||
|
audioStreamSetVolume(state->stream, volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioMixerChannelUpdateEarly(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
audiomixerchannelstate_t *state
|
||||||
|
) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
audiomixercommand_t *command = &state->pending;
|
||||||
|
|
||||||
|
if(command->command == AUDIO_MIXER_COMMAND_STOP) {
|
||||||
|
errorChain(audioMixerChannelStopStream(state));
|
||||||
|
command->command = AUDIO_MIXER_COMMAND_NONE;
|
||||||
|
audioMixerChannelApplyVolume(channel, state);
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(
|
||||||
|
command->command != AUDIO_MIXER_COMMAND_PLAY ||
|
||||||
|
command->loadingAsset == NULL
|
||||||
|
) {
|
||||||
|
// A PLAY command with no loadingAsset yet (command->loadingAsset ==
|
||||||
|
// NULL) was queued too late for the audioMixerChannelUpdateLate() that
|
||||||
|
// just ran immediately before this call to have locked it - the only
|
||||||
|
// way that happens is a command queued outside the normal per-frame
|
||||||
|
// cycle, e.g. during engine startup before any
|
||||||
|
// audioMixerChannelUpdateLate() has run yet. Left alone (still
|
||||||
|
// AUDIO_MIXER_COMMAND_PLAY) rather than treated as an error: the very
|
||||||
|
// next audioMixerChannelUpdateLate() will lock it, and the frame after
|
||||||
|
// that will reach here and apply it normally.
|
||||||
|
audioMixerChannelApplyVolume(channel, state);
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks here if audioMixerChannelUpdateLate()'s load (kicked off at the
|
||||||
|
// end of the previous frame) hasn't finished yet - see this function's
|
||||||
|
// own doc comment on why that's accepted for now.
|
||||||
|
errorret_t loadRet = assetRequireLoaded(command->loadingAsset);
|
||||||
|
if(errorIsNotOk(loadRet)) {
|
||||||
|
assetUnlockEntry(command->loadingAsset);
|
||||||
|
command->loadingAsset = NULL;
|
||||||
|
command->command = AUDIO_MIXER_COMMAND_NONE;
|
||||||
|
errorChain(loadRet);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorChain(audioMixerChannelStopStream(state));
|
||||||
|
|
||||||
|
audiostream_t *stream = audioAquireStream(command->loadingAsset);
|
||||||
|
assertNotNull(stream, "No free audio stream slots available.");
|
||||||
|
|
||||||
|
errorret_t initRet = audioStreamInit(stream, command->loadingAsset);
|
||||||
|
// The stream holds its own lock on the asset once initialized (or, on
|
||||||
|
// failure, audioStreamInit() has already released it itself) - either
|
||||||
|
// way this call's own lock is no longer needed.
|
||||||
|
assetUnlockEntry(command->loadingAsset);
|
||||||
|
command->loadingAsset = NULL;
|
||||||
|
command->command = AUDIO_MIXER_COMMAND_NONE;
|
||||||
|
errorChain(initRet);
|
||||||
|
|
||||||
|
// Forwards to this channel's own onLoop/onEnd (persistent across every
|
||||||
|
// play on this channel) rather than this one stream instance's own -
|
||||||
|
// see audiomixerchannelstate_t.onLoop's own comment.
|
||||||
|
stream->user = state;
|
||||||
|
stream->onLoop = audioMixerChannelOnStreamLoop;
|
||||||
|
stream->onEnd = audioMixerChannelOnStreamEnd;
|
||||||
|
|
||||||
|
audioStreamSetDirectionality(stream, command->pan);
|
||||||
|
if(command->looping) {
|
||||||
|
audioStreamSetLooping(stream, true);
|
||||||
|
audioStreamSetLoopPoints(stream, command->loopStart, command->loopTo);
|
||||||
|
audioStreamSetLoopLimit(stream, command->loopCount);
|
||||||
|
}
|
||||||
|
audioStreamPlay(stream);
|
||||||
|
|
||||||
|
state->stream = stream;
|
||||||
|
state->baseVolume = command->volume;
|
||||||
|
|
||||||
|
audioMixerChannelApplyVolume(channel, state);
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state) {
|
||||||
|
assertNotNull(state, "State cannot be NULL.");
|
||||||
|
|
||||||
|
audiomixercommand_t *command = &state->pending;
|
||||||
|
|
||||||
|
if(command->command != AUDIO_MIXER_COMMAND_PLAY) errorOk();
|
||||||
|
|
||||||
|
assetloadertype_t assetType = audioStreamAssetTypeForPath(command->file);
|
||||||
|
assertTrue(
|
||||||
|
assetType != ASSET_LOADER_TYPE_NULL, "Unsupported audio file extension."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Begins loading now - kicked off at the end of this frame so it has
|
||||||
|
// until the start of the next (audioMixerChannelUpdateEarly()) to finish
|
||||||
|
// before that has to block on it.
|
||||||
|
command->loadingAsset = assetLock(command->file, assetType, NULL);
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerChannelOnStreamLoop(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user;
|
||||||
|
if(state->onLoop != NULL) state->onLoop(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioMixerChannelOnStreamEnd(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
audiomixerchannelstate_t *state = (audiomixerchannelstate_t *) stream->user;
|
||||||
|
if(state->onEnd != NULL) state->onEnd(state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "dusk.h"
|
||||||
|
#include "error/error.h"
|
||||||
|
#include "animation/easing.h"
|
||||||
|
|
||||||
|
// Forward-declared rather than including asset/asset.h - this header only
|
||||||
|
// ever needs a pointer to it, and pulling in the full asset module here
|
||||||
|
// creates a real include cycle for anything that reaches this header via
|
||||||
|
// asset/loader/cutscene/assetcutsceneloader.h (asset.h -> ... ->
|
||||||
|
// assetloader.h -> assetcutsceneloader.h -> cutscene.h -> cutsceneitem.h
|
||||||
|
// -> the audio cutscene items -> this header -> asset/asset.h again,
|
||||||
|
// mid-parse). audiomixerchannel.c itself still includes the full header.
|
||||||
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
|
typedef struct audiostream_s audiostream_t;
|
||||||
|
|
||||||
|
#define AUDIO_PATH_MAX 256
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
AUDIO_MIXER_CHANNEL_BGM_0,
|
||||||
|
AUDIO_MIXER_CHANNEL_VOICE_0,
|
||||||
|
AUDIO_MIXER_CHANNEL_VOICE_1,
|
||||||
|
AUDIO_MIXER_CHANNEL_SFX_0,
|
||||||
|
AUDIO_MIXER_CHANNEL_SFX_1,
|
||||||
|
AUDIO_MIXER_CHANNEL_SFX_2,
|
||||||
|
AUDIO_MIXER_CHANNEL_SFX_3,
|
||||||
|
AUDIO_MIXER_CHANNEL_COUNT
|
||||||
|
} audiomixerchannel_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
AUDIO_MIXER_COMMAND_NONE,
|
||||||
|
AUDIO_MIXER_COMMAND_PLAY,
|
||||||
|
AUDIO_MIXER_COMMAND_STOP
|
||||||
|
} audiomixercommandtype_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
// What audioMixerChannelUpdateEarly() should do to this channel at the
|
||||||
|
// start of the next frame - AUDIO_MIXER_COMMAND_NONE if nothing's
|
||||||
|
// queued. Set by audioMixerPlay()/audioMixerPlayLooped()/
|
||||||
|
// audioMixerStop(); queuing a new command on a channel replaces, rather
|
||||||
|
// than stacks on top of, whatever was already queued for it this frame -
|
||||||
|
// only the most recent survives.
|
||||||
|
audiomixercommandtype_t command;
|
||||||
|
|
||||||
|
// AUDIO_MIXER_COMMAND_PLAY only, below.
|
||||||
|
char_t file[AUDIO_PATH_MAX];
|
||||||
|
float_t volume;
|
||||||
|
float_t pan;
|
||||||
|
bool_t looping;
|
||||||
|
uint8_t loopCount;
|
||||||
|
float_t loopStart;
|
||||||
|
float_t loopTo;
|
||||||
|
|
||||||
|
// Set by audioMixerChannelUpdateLate() (the end of the frame this PLAY
|
||||||
|
// command was queued on), once it locks the requested file and kicks off
|
||||||
|
// its load. audioMixerChannelUpdateEarly() (the start of the next frame)
|
||||||
|
// blocks on this being fully loaded before actually starting playback -
|
||||||
|
// see its own comment. NULL except in that in-between window.
|
||||||
|
assetentry_t *loadingAsset;
|
||||||
|
} audiomixercommand_t;
|
||||||
|
|
||||||
|
typedef struct audiomixerchannelstate_s audiomixerchannelstate_t;
|
||||||
|
|
||||||
|
typedef struct audiomixerchannelstate_s {
|
||||||
|
// Queued action for the next audioMixerChannelUpdateEarly() to apply.
|
||||||
|
audiomixercommand_t pending;
|
||||||
|
|
||||||
|
// This channel's currently playing stream, or NULL if nothing is -
|
||||||
|
// acquired from AUDIO.streams by audioMixerChannelUpdateEarly() when it
|
||||||
|
// applies an AUDIO_MIXER_COMMAND_PLAY, disposed the same way on
|
||||||
|
// AUDIO_MIXER_COMMAND_STOP or before a new AUDIO_MIXER_COMMAND_PLAY
|
||||||
|
// replaces it.
|
||||||
|
audiostream_t *stream;
|
||||||
|
|
||||||
|
// The per-sound volume `stream` was last started with (the `volume`
|
||||||
|
// argument to whichever audioMixerPlay()/audioMixerPlayLooped() call
|
||||||
|
// started it) - combined with this channel's fade, its own volume
|
||||||
|
// (SAVE.settings.audioChannelVolume) and the mixer's master volume
|
||||||
|
// (SAVE.settings.audioMasterVolume), every frame, to get the volume
|
||||||
|
// actually passed to audioStreamSetVolume(). Meaningless while `stream`
|
||||||
|
// is NULL.
|
||||||
|
float_t baseVolume;
|
||||||
|
|
||||||
|
// This channel's own fade multiplier, from 0.0 (fully faded out) to 1.0
|
||||||
|
// (no fade applied, the default) - transient runtime state, unlike the
|
||||||
|
// channel/master volumes below (which are persisted user preferences).
|
||||||
|
// No setter - write it directly; audioMixerChannelApplyVolume() asserts
|
||||||
|
// it's in range the next time it reads it, rather than validating at the
|
||||||
|
// point it's set.
|
||||||
|
float_t fade;
|
||||||
|
|
||||||
|
// Fade transition state, set by audioMixerChannelFadeTo() and advanced
|
||||||
|
// every frame by audioMixerChannelApplyVolume(), before it reads `fade`
|
||||||
|
// to compute the mixed volume. fadeTime < 0 means no fade is in progress
|
||||||
|
// (audioMixerChannelIsFading() is just `fadeTime >= 0`) - audioMixerInit()
|
||||||
|
// explicitly sets this to -1.0f, since zero-initializing it would
|
||||||
|
// otherwise look like an active fade at time 0.
|
||||||
|
float_t fadeFrom;
|
||||||
|
float_t fadeTo;
|
||||||
|
float_t fadeDuration;
|
||||||
|
float_t fadeTime;
|
||||||
|
easingtype_t fadeEasing;
|
||||||
|
|
||||||
|
// Arbitrary caller data, handed back as-is to onLoop/onEnd below - never
|
||||||
|
// read or written by the mixer itself. NULL by default.
|
||||||
|
void *user;
|
||||||
|
|
||||||
|
// Fired (from the main thread - safe to do arbitrary work, same
|
||||||
|
// guarantee as audiostream_t's own onLoop/onEnd) whenever this channel's
|
||||||
|
// currently playing stream loops, or reaches its natural end (including
|
||||||
|
// hitting its loop limit - see audioStreamSetLoopLimit()) and stops.
|
||||||
|
// Wired onto every stream this channel starts via
|
||||||
|
// audioMixerChannelOnStreamLoop()/OnStreamEnd() - not called directly by
|
||||||
|
// anything else. No setter - write these fields directly; NULL (the
|
||||||
|
// default) means not notified. Unlike `stream`/`pending`, these persist
|
||||||
|
// across every play on this channel, not just one.
|
||||||
|
void (*onLoop)(audiomixerchannelstate_t *state);
|
||||||
|
void (*onEnd)(audiomixerchannelstate_t *state);
|
||||||
|
} audiomixerchannelstate_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops and disposes the given channel's currently playing stream, if any -
|
||||||
|
* a safe no-op if the channel is already idle. Shared by
|
||||||
|
* audioMixerChannelUpdateEarly() (AUDIO_MIXER_COMMAND_STOP, and replacing a
|
||||||
|
* channel's stream before a new AUDIO_MIXER_COMMAND_PLAY starts) and
|
||||||
|
* audioMixerDispose().
|
||||||
|
*
|
||||||
|
* @param state The channel state to stop.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerChannelStopStream(audiomixerchannelstate_t *state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the given channel's actual playback volume - its currently
|
||||||
|
* playing sound's own volume (baseVolume), this channel's fade, this
|
||||||
|
* channel's own volume (SAVE.settings.audioChannelVolume[channel]) and the
|
||||||
|
* mixer's master volume (SAVE.settings.audioMasterVolume) - and applies it
|
||||||
|
* via audioStreamSetVolume(). Asserts fade and both save-settings volumes
|
||||||
|
* are each within [0.0, 1.0] - the only validation any of them ever gets,
|
||||||
|
* since none of them have a setter. A safe no-op if the channel isn't
|
||||||
|
* playing anything. Called every audioMixerChannelUpdateEarly(), not just
|
||||||
|
* when a PLAY command starts a new stream - see its own comment.
|
||||||
|
*
|
||||||
|
* @param channel Which channel `state` is, to index
|
||||||
|
* SAVE.settings.audioChannelVolume with.
|
||||||
|
* @param state The channel state to update.
|
||||||
|
*/
|
||||||
|
void audioMixerChannelApplyVolume(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
audiomixerchannelstate_t *state
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts fading the given channel's `fade` multiplier from `from` to `to`
|
||||||
|
* over `duration` seconds, eased by `easing` - snaps `fade` to `from`
|
||||||
|
* immediately, then audioMixerChannelApplyVolume() advances it every frame
|
||||||
|
* from here on. A `duration` of 0 or less snaps straight to `to` and marks
|
||||||
|
* the fade as already finished (audioMixerChannelIsFading() returns
|
||||||
|
* false on the very next call).
|
||||||
|
*
|
||||||
|
* @param state The channel state to fade.
|
||||||
|
* @param from Starting fade value, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param to Ending fade value, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
* @param duration How long the fade takes, in seconds.
|
||||||
|
* @param easing Easing curve to apply to the fade's progress.
|
||||||
|
*/
|
||||||
|
void audioMixerChannelFadeTo(
|
||||||
|
audiomixerchannelstate_t *state,
|
||||||
|
const float_t from,
|
||||||
|
const float_t to,
|
||||||
|
const float_t duration,
|
||||||
|
const easingtype_t easing
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given channel currently has a fade in progress (started via
|
||||||
|
* audioMixerChannelFadeTo() and not yet finished).
|
||||||
|
*
|
||||||
|
* @param state The channel state to check.
|
||||||
|
* @return true if a fade is still in progress.
|
||||||
|
*/
|
||||||
|
bool_t audioMixerChannelIsFading(const audiomixerchannelstate_t *state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single mixer channel, called before rendering (the start of
|
||||||
|
* the frame): applies whatever command (play, stop - including everything
|
||||||
|
* a play carries, pan/looping) audioMixerChannelUpdateLate() queued for
|
||||||
|
* loading at the end of the previous frame. If that load hasn't finished
|
||||||
|
* yet, this blocks (via assetRequireLoaded()) until it has - a deliberate
|
||||||
|
* stall for now rather than skipping a frame, since there's currently no
|
||||||
|
* way to only partially apply a channel's command. Also recomputes and
|
||||||
|
* reapplies this channel's mixed volume (see
|
||||||
|
* audioMixerChannelApplyVolume()) every time, regardless of whether a
|
||||||
|
* command was queued this frame, so a live change to this channel's fade,
|
||||||
|
* its own volume, or the master volume takes effect on whatever's already
|
||||||
|
* playing too.
|
||||||
|
*
|
||||||
|
* @param channel Which channel `state` is - see
|
||||||
|
* audioMixerChannelApplyVolume()'s own comment.
|
||||||
|
* @param state The channel state to update.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerChannelUpdateEarly(
|
||||||
|
const audiomixerchannel_t channel,
|
||||||
|
audiomixerchannelstate_t *state
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single mixer channel, called at the end of each frame, once
|
||||||
|
* every other system has had a chance to queue a command via
|
||||||
|
* audioMixerPlay()/audioMixerPlayLooped()/audioMixerStop(). For a queued
|
||||||
|
* AUDIO_MIXER_COMMAND_PLAY, locks its file and begins loading it (see
|
||||||
|
* assetLock()) - giving it from now until the next
|
||||||
|
* audioMixerChannelUpdateEarly() to finish before that has to block on it.
|
||||||
|
* Actually starting/stopping playback happens there, not here.
|
||||||
|
*
|
||||||
|
* @param state The channel state to update.
|
||||||
|
*/
|
||||||
|
errorret_t audioMixerChannelUpdateLate(audiomixerchannelstate_t *state);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trampoline wired onto every stream a channel starts (as that stream's
|
||||||
|
* onLoop) - forwards to the channel's own onLoop (see
|
||||||
|
* audiomixerchannelstate_t.onLoop's own comment) via stream->user, which
|
||||||
|
* audioMixerChannelUpdateEarly() points at the channel's own state.
|
||||||
|
*
|
||||||
|
* @param stream The stream that looped.
|
||||||
|
*/
|
||||||
|
void audioMixerChannelOnStreamLoop(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as audioMixerChannelOnStreamLoop() but wired onto onEnd, forwarding
|
||||||
|
* to the channel's own onEnd.
|
||||||
|
*
|
||||||
|
* @param stream The stream that ended.
|
||||||
|
*/
|
||||||
|
void audioMixerChannelOnStreamEnd(audiostream_t *stream);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Copyright (c) 2026 Dominic Masters
|
||||||
|
#
|
||||||
|
# This software is released under the MIT License.
|
||||||
|
# https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
# Sources
|
||||||
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
|
PUBLIC
|
||||||
|
audiostream.c
|
||||||
|
audiostreampcm.c
|
||||||
|
audiostreammp3.c
|
||||||
|
audiostreamtype.c
|
||||||
|
)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audiostream.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
|
||||||
|
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(asset, "Asset cannot be NULL.");
|
||||||
|
assertTrue(
|
||||||
|
asset->state == ASSET_ENTRY_STATE_LOADED,
|
||||||
|
"Asset must be loaded before it can back an audio stream."
|
||||||
|
);
|
||||||
|
// A caller passing an asset type that can't back an audio stream is a
|
||||||
|
// programmer error, not something that can happen from untrusted data, so
|
||||||
|
// this is an assert rather than an errorThrow (see
|
||||||
|
// feedback_assert_vs_error convention).
|
||||||
|
assertTrue(
|
||||||
|
audioStreamTypeForAssetType(asset->type) != AUDIO_STREAM_TYPE_NULL,
|
||||||
|
"Unsupported asset type for an audio stream."
|
||||||
|
);
|
||||||
|
|
||||||
|
stream->state = 0;
|
||||||
|
stream->volume = 1.0f;
|
||||||
|
stream->directionality = AUDIO_STREAM_CENTER;
|
||||||
|
stream->loopStart = -1;
|
||||||
|
stream->loopTo = 0;
|
||||||
|
stream->duration = 0;
|
||||||
|
stream->startFrame = 0;
|
||||||
|
stream->seeking = false;
|
||||||
|
stream->user = NULL;
|
||||||
|
stream->onLoop = NULL;
|
||||||
|
stream->onEnd = NULL;
|
||||||
|
stream->loopCount = 0;
|
||||||
|
stream->lastLoopCount = 0;
|
||||||
|
stream->loopLimit = 0;
|
||||||
|
stream->loopRestartCount = 0;
|
||||||
|
|
||||||
|
// Locked for as long as the stream is in use (see audiostream_t.asset's
|
||||||
|
// own comment) - released in audioStreamDispose().
|
||||||
|
stream->asset = asset;
|
||||||
|
assetEntryLock(asset);
|
||||||
|
|
||||||
|
// Type-specific setup (audioStreamPcmInit() / audioStreamMp3Init())
|
||||||
|
// determines stream->type, sets stream->sampleRate/channels, and asks
|
||||||
|
// the platform implementation to set up its state.
|
||||||
|
errorret_t ret = asset->type == ASSET_LOADER_TYPE_MP3
|
||||||
|
? audioStreamMp3Init(stream)
|
||||||
|
: audioStreamPcmInit(stream);
|
||||||
|
if(errorIsNotOk(ret)) {
|
||||||
|
assetEntryUnlock(asset);
|
||||||
|
stream->asset = NULL;
|
||||||
|
errorChain(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t audioStreamGetTotalFrames(const audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
return stream->type == AUDIO_STREAM_TYPE_MP3
|
||||||
|
? audioStreamMp3GetTotalFrames(stream)
|
||||||
|
: audioStreamPcmGetTotalFrames(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
errorChain(
|
||||||
|
stream->type == AUDIO_STREAM_TYPE_MP3
|
||||||
|
? audioStreamMp3Seek(stream, frame)
|
||||||
|
: audioStreamPcmSeek(stream, frame)
|
||||||
|
);
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamRead(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
errorChain(
|
||||||
|
stream->type == AUDIO_STREAM_TYPE_MP3
|
||||||
|
? audioStreamMp3Read(stream, buffer, frameCount, outFramesRead)
|
||||||
|
: audioStreamPcmRead(stream, buffer, frameCount, outFramesRead)
|
||||||
|
);
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamPlay(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
stream->state |= AUDIO_STREAM_STATE_PLAYING;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamPause(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
stream->state &= ~AUDIO_STREAM_STATE_PLAYING;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetPosition(audiostream_t *stream, const float_t position) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
// Wrap into [0, duration).
|
||||||
|
float_t t = position;
|
||||||
|
while(t < 0) t += stream->duration;
|
||||||
|
while(t >= stream->duration) t -= stream->duration;
|
||||||
|
|
||||||
|
stream->startFrame = (size_t) (t * stream->sampleRate);
|
||||||
|
stream->seeking = true;
|
||||||
|
|
||||||
|
// Force the next audioStreamUpdate() to re-buffer from startFrame instead
|
||||||
|
// of continuing whatever was already buffered - see the platform Buffer()
|
||||||
|
// implementations for how each one applies startFrame; PSP is the one
|
||||||
|
// exception (see audioStreamSetPosition's own doc comment).
|
||||||
|
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetLoopPoints(
|
||||||
|
audiostream_t *stream,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(
|
||||||
|
loopStart < 0 || loopTo < loopStart,
|
||||||
|
"loopTo must be before loopStart."
|
||||||
|
);
|
||||||
|
|
||||||
|
stream->loopStart = loopStart;
|
||||||
|
stream->loopTo = loopTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamGetPanFactors(
|
||||||
|
const audiostream_t *stream,
|
||||||
|
float_t *outLeft,
|
||||||
|
float_t *outRight
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(outLeft, "outLeft cannot be NULL.");
|
||||||
|
assertNotNull(outRight, "outRight cannot be NULL.");
|
||||||
|
|
||||||
|
// directionality is already a pan factor in AUDIO_STREAM_LEFT..RIGHT
|
||||||
|
// (-1.0..1.0) - assertTrue'd here rather than clamped, since a caller
|
||||||
|
// passing something outside that range is a programmer error (see
|
||||||
|
// audioStreamSetDirectionality()'s own assert).
|
||||||
|
const float_t pan = stream->directionality;
|
||||||
|
|
||||||
|
*outLeft = pan > 0 ? (1.0f - pan) : 1.0f;
|
||||||
|
*outRight = pan < 0 ? (1.0f + pan) : 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t audioStreamComputeEndFrame(
|
||||||
|
const audiostream_t *stream,
|
||||||
|
const size_t startFrame,
|
||||||
|
const size_t totalFrames
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
size_t endFrame = totalFrames;
|
||||||
|
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && stream->loopStart >= 0) {
|
||||||
|
endFrame = mathMin(
|
||||||
|
(size_t) (stream->loopStart * stream->sampleRate), totalFrames
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if(startFrame >= endFrame) endFrame = totalFrames;
|
||||||
|
|
||||||
|
return endFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetVolume(audiostream_t *stream, const float_t volume) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(volume >= 0.0f && volume <= 1.0f, "Volume is out of range.");
|
||||||
|
stream->volume = volume;
|
||||||
|
// TODO: Do I need to update the device output? PSP may require this
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetDirectionality(
|
||||||
|
audiostream_t *stream,
|
||||||
|
const float_t directionality
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(
|
||||||
|
directionality >= AUDIO_STREAM_LEFT && directionality <= AUDIO_STREAM_RIGHT,
|
||||||
|
"Directionality is out of range."
|
||||||
|
);
|
||||||
|
stream->directionality = directionality;
|
||||||
|
// TODO: Need to update internal decoder?
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
if(looping) {
|
||||||
|
stream->state |= AUDIO_STREAM_STATE_LOOPING;
|
||||||
|
} else {
|
||||||
|
stream->state &= ~AUDIO_STREAM_STATE_LOOPING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void audioStreamSetLoopLimit(audiostream_t *stream, const uint32_t loopLimit) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
stream->loopLimit = loopLimit;
|
||||||
|
stream->loopRestartCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamUpdate(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
if(stream->type == AUDIO_STREAM_TYPE_NULL) {
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some platforms (PSP) loop entirely on their own background thread for
|
||||||
|
// gaplessness and can only safely notify onLoop by incrementing this
|
||||||
|
// counter from there, rather than calling onLoop directly off the main
|
||||||
|
// thread - see loopCount's own comment. Firing it here, unconditionally,
|
||||||
|
// catches up to the latest count in one call even if multiple loops
|
||||||
|
// happened between two Update() calls.
|
||||||
|
if(stream->loopCount != stream->lastLoopCount) {
|
||||||
|
stream->lastLoopCount = stream->loopCount;
|
||||||
|
|
||||||
|
if(stream->onLoop != NULL) {
|
||||||
|
stream->onLoop(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Once streamed (rather than fully in-memory) sources exist, this is
|
||||||
|
// where new data would be decoded/read in and re-buffered as playback
|
||||||
|
// consumes it.
|
||||||
|
// Checked in this order (finished-check before needs-buffering) so that a
|
||||||
|
// loop restart falls straight through into re-buffering within this same
|
||||||
|
// call, instead of leaving BUFFERED cleared for the caller to notice and
|
||||||
|
// act on next frame - that extra frame of latency was audible as a gap
|
||||||
|
// at every loop boundary.
|
||||||
|
if(
|
||||||
|
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
|
||||||
|
(stream->state & AUDIO_STREAM_STATE_BUFFERED) &&
|
||||||
|
audioStreamPlatformIsFinished(stream)
|
||||||
|
) {
|
||||||
|
// A limit of 0 means unlimited; otherwise this pass's restart only goes
|
||||||
|
// ahead while fewer than loopLimit restarts have already happened -
|
||||||
|
// once it's reached, fall through to the same stop/onEnd path as a
|
||||||
|
// non-looping stream instead of restarting again.
|
||||||
|
const bool_t loopLimitReached = stream->loopLimit > 0 &&
|
||||||
|
stream->loopRestartCount >= stream->loopLimit;
|
||||||
|
|
||||||
|
if((stream->state & AUDIO_STREAM_STATE_LOOPING) && !loopLimitReached) {
|
||||||
|
stream->state &= ~AUDIO_STREAM_STATE_BUFFERED;
|
||||||
|
stream->loopRestartCount++;
|
||||||
|
|
||||||
|
// Resume from loopTo rather than the very start of the buffer - only
|
||||||
|
// matters for platforms that reach this generic restart path at all
|
||||||
|
// (Linux and Dolphin, both of which re-buffer per pass and report
|
||||||
|
// "finished" at every loop boundary); PSP loops entirely on its own
|
||||||
|
// read-ahead ring instead (see loopRestartCount's own comment).
|
||||||
|
stream->startFrame = (size_t) (stream->loopTo * stream->sampleRate);
|
||||||
|
|
||||||
|
if(stream->onLoop != NULL) {
|
||||||
|
stream->onLoop(stream);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream->state &=
|
||||||
|
~(AUDIO_STREAM_STATE_PLAYING | AUDIO_STREAM_STATE_BUFFERED);
|
||||||
|
|
||||||
|
if(stream->onEnd != NULL) {
|
||||||
|
stream->onEnd(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(
|
||||||
|
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
|
||||||
|
!(stream->state & AUDIO_STREAM_STATE_BUFFERED)
|
||||||
|
) {
|
||||||
|
errorChain(audioStreamPlatformBuffer(stream));
|
||||||
|
stream->state |= AUDIO_STREAM_STATE_BUFFERED;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamDispose(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
|
||||||
|
if(stream->type != AUDIO_STREAM_TYPE_NULL) {
|
||||||
|
errorChain(audioStreamPlatformDispose(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(stream->type == AUDIO_STREAM_TYPE_PCM) {
|
||||||
|
errorChain(audioStreamPcmDispose(stream));
|
||||||
|
} else if(stream->type == AUDIO_STREAM_TYPE_MP3) {
|
||||||
|
errorChain(audioStreamMp3Dispose(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(stream->asset != NULL) {
|
||||||
|
assetEntryUnlock(stream->asset);
|
||||||
|
stream->asset = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream->type = AUDIO_STREAM_TYPE_NULL;
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "audio/stream/audiostreamtype.h"
|
||||||
|
#include "audio/stream/audiostreampcm.h"
|
||||||
|
#include "audio/stream/audiostreammp3.h"
|
||||||
|
#include "audio/audiostreamplatform.h"
|
||||||
|
#include "asset/loader/assetentry.h"
|
||||||
|
|
||||||
|
#ifndef audioStreamPlatformInit
|
||||||
|
#error "audioStreamPlatformInit is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef audioStreamPlatformDispose
|
||||||
|
#error "audioStreamPlatformDispose is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef audioStreamPlatformBuffer
|
||||||
|
#error "audioStreamPlatformBuffer is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef audioStreamPlatformIsFinished
|
||||||
|
#error "audioStreamPlatformIsFinished is not defined"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define AUDIO_STREAMS_MAX 8
|
||||||
|
#define AUDIO_STREAM_STATE_PLAYING (1 << 0)
|
||||||
|
#define AUDIO_STREAM_STATE_LOOPING (1 << 1)
|
||||||
|
#define AUDIO_STREAM_STATE_BUFFERED (1 << 2)
|
||||||
|
#define AUDIO_STREAM_CENTER 0.0f
|
||||||
|
#define AUDIO_STREAM_LEFT -1.0f
|
||||||
|
#define AUDIO_STREAM_RIGHT 1.0f
|
||||||
|
|
||||||
|
typedef struct audiostream_s audiostream_t;
|
||||||
|
|
||||||
|
typedef struct audiostream_s {
|
||||||
|
// Used for aquiring new data.
|
||||||
|
audistreamtype_t type;
|
||||||
|
|
||||||
|
// What state the stream is in. volatile: PSP's output thread reads
|
||||||
|
// AUDIO_STREAM_STATE_PLAYING directly to honor a pause promptly instead
|
||||||
|
// of only reacting once the main thread's own per-frame gate notices -
|
||||||
|
// see audioStreamPSPThreadFeed()'s own comment.
|
||||||
|
volatile uint8_t state;
|
||||||
|
|
||||||
|
// The asset backing this stream's data (e.g. a WAV file) - determines
|
||||||
|
// `type` (see audioStreamInit()) and is read from on demand by the
|
||||||
|
// type-specific module (e.g. audiostreampcm.h) rather than ever being
|
||||||
|
// fully decoded into memory up front. The stream holds its own lock on
|
||||||
|
// this entry (assetEntryLock()/assetEntryUnlock()) for as long as it's
|
||||||
|
// in use, independent of whatever lock(s) the caller that requested
|
||||||
|
// playback may also be holding.
|
||||||
|
assetentry_t *asset;
|
||||||
|
|
||||||
|
// Loudness, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
float_t volume;
|
||||||
|
|
||||||
|
// In stereo space, where do we send the audio - from AUDIO_STREAM_LEFT
|
||||||
|
// (-1.0) to AUDIO_STREAM_RIGHT (1.0), AUDIO_STREAM_CENTER (0.0) by
|
||||||
|
// default.
|
||||||
|
// TODO: Can we use a 3D vector + Pro Logic II?
|
||||||
|
float_t directionality;
|
||||||
|
|
||||||
|
// In seconds, where the loop segment ends and playback jumps back to
|
||||||
|
// loopTo - -1 (the default) means "at the end of the stream", i.e. loop
|
||||||
|
// the whole buffer. Set via audioStreamSetLoopPoints().
|
||||||
|
float_t loopStart;
|
||||||
|
|
||||||
|
// In seconds, where a loop jumps back to once it reaches loopStart.
|
||||||
|
// Defaults to 0 (start of stream). Set via audioStreamSetLoopPoints().
|
||||||
|
float_t loopTo;
|
||||||
|
|
||||||
|
// Cached duration of the stream in seconds.
|
||||||
|
float_t duration;
|
||||||
|
|
||||||
|
// Frame offset the next platform Buffer() call should start playback
|
||||||
|
// from - set by audioStreamSetPosition() (an explicit seek) and by
|
||||||
|
// audioStreamUpdate() itself (to loopTo's frame offset, for platforms
|
||||||
|
// that re-enter Buffer() on every loop restart rather than looping
|
||||||
|
// natively). Each platform's Buffer()-invoking entry point must read and
|
||||||
|
// reset this to 0 synchronously, in the same call that decided to
|
||||||
|
// (re)buffer - not later/asynchronously (e.g. from a feeder thread),
|
||||||
|
// since audioStreamUpdate() may already have moved on to something else
|
||||||
|
// that touches this field by the time an async reader gets to it.
|
||||||
|
size_t startFrame;
|
||||||
|
|
||||||
|
// True when startFrame came from an explicit audioStreamSetPosition()
|
||||||
|
// seek rather than a natural loop restart. Platforms whose Buffer() call
|
||||||
|
// can leave previously-queued audio still playing underneath the new
|
||||||
|
// pass (currently just Linux's SDL queue, kept deliberately overlapping
|
||||||
|
// across a loop restart to avoid a gap) need this to tell "jump now,
|
||||||
|
// discarding whatever's still playing" (seek) apart from "let the old
|
||||||
|
// tail keep playing while the new pass queues underneath it" (loop
|
||||||
|
// restart) - both look identical as just "a pending startFrame"
|
||||||
|
// otherwise. Consumed (reset to false) the same way as startFrame.
|
||||||
|
bool_t seeking;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
void *user;
|
||||||
|
void (*onLoop)(audiostream_t *stream);
|
||||||
|
void (*onEnd)(audiostream_t *stream);
|
||||||
|
|
||||||
|
// Incremented by platform code (from whatever thread/context it runs in)
|
||||||
|
// each time a loop happens, instead of calling onLoop directly - onLoop
|
||||||
|
// may do arbitrary, possibly-slow work (console printing, game logic),
|
||||||
|
// which is only safe to run from the main thread inside audioStreamUpdate().
|
||||||
|
// Calling it straight from a real-time audio thread risks starving the
|
||||||
|
// hardware buffer if it takes too long - confirmed as the real cause of
|
||||||
|
// a loud crackle on PSP once its feeder thread called onLoop inline.
|
||||||
|
volatile uint32_t loopCount;
|
||||||
|
|
||||||
|
// audioStreamUpdate()'s own record of the last loopCount it fired
|
||||||
|
// onLoop for - only ever touched from the main thread.
|
||||||
|
uint32_t lastLoopCount;
|
||||||
|
|
||||||
|
// Maximum number of times this stream is allowed to loop before stopping
|
||||||
|
// (firing onEnd) instead of restarting again - 0 (the default) means loop
|
||||||
|
// forever. Has no effect unless looping is also enabled via
|
||||||
|
// audioStreamSetLooping(). Set via audioStreamSetLoopLimit().
|
||||||
|
uint32_t loopLimit;
|
||||||
|
|
||||||
|
// How many loop restarts this stream has already committed to - compared
|
||||||
|
// against loopLimit each time a restart is considered, then incremented
|
||||||
|
// if it goes ahead; reset to 0 by audioStreamSetLoopLimit(). Distinct
|
||||||
|
// from loopCount (which tracks when a loop becomes audible, purely for
|
||||||
|
// onLoop notification, and is PSP-only) - this counts decided restarts,
|
||||||
|
// main-thread-only on every platform (PSP decides whether to loop from
|
||||||
|
// its own read-ahead ring, itself driven from the main thread via
|
||||||
|
// audioStreamPSPIsFinished() - see its own comment).
|
||||||
|
uint32_t loopRestartCount;
|
||||||
|
|
||||||
|
// PCM format of the stream's decoded output - read identically by every
|
||||||
|
// platform backend regardless of stream type (PCM decodes straight
|
||||||
|
// through; MP3 decodes to this same format), so it lives here rather
|
||||||
|
// than duplicated in both audiostreampcm_t and audiostreammp3_t. Set by
|
||||||
|
// whichever type-specific Init function runs (audioStreamPcmInit() /
|
||||||
|
// audioStreamMp3Init()).
|
||||||
|
uint32_t sampleRate;
|
||||||
|
uint8_t channels;
|
||||||
|
|
||||||
|
// Stream type specific data.
|
||||||
|
union {
|
||||||
|
audiostreampcm_t pcm;
|
||||||
|
audiostreammp3_t mp3;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Platform-specific playback state (e.g. SDL2 device, PSP channel, ASND
|
||||||
|
// voice). Defined by each platform's audiostreamplatform.h.
|
||||||
|
audiostreamplatform_t platform;
|
||||||
|
} audiostream_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes an audio stream to play the given asset, determining the
|
||||||
|
* stream's type from the asset's loader type (e.g. a WAV asset becomes an
|
||||||
|
* AUDIO_STREAM_TYPE_PCM stream) and dispatching to that type's own setup
|
||||||
|
* (e.g. audioStreamPcmInit()). Does not begin playback; call
|
||||||
|
* audioStreamPlay() once this returns.
|
||||||
|
*
|
||||||
|
* The stream takes its own lock on `asset` (see audiostream_t.asset's own
|
||||||
|
* comment), released by audioStreamDispose() - the asset must already be
|
||||||
|
* loaded (ASSET_ENTRY_STATE_LOADED) when this is called.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to initialize.
|
||||||
|
* @param asset The loaded asset to play - its type must be one this
|
||||||
|
* function supports (ASSET_LOADER_TYPE_WAV or
|
||||||
|
* ASSET_LOADER_TYPE_MP3).
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamInit(audiostream_t *stream, assetentry_t *asset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of decoded PCM frames (one sample per channel)
|
||||||
|
* available in the stream's underlying data, regardless of its type -
|
||||||
|
* dispatches to audioStreamPcmGetTotalFrames() or
|
||||||
|
* audioStreamMp3GetTotalFrames(). Platform backends should use this rather
|
||||||
|
* than a type-specific function directly, so they don't need to know or
|
||||||
|
* care which type they're driving.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to query.
|
||||||
|
* @return The total number of frames available.
|
||||||
|
*/
|
||||||
|
size_t audioStreamGetTotalFrames(const audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeks the stream's decode position to the given frame offset, regardless
|
||||||
|
* of its type - dispatches to audioStreamPcmSeek() or audioStreamMp3Seek().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to seek.
|
||||||
|
* @param frame Frame offset to seek to, relative to the start of the
|
||||||
|
* stream's decoded data.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamSeek(audiostream_t *stream, const size_t frame);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads up to frameCount frames of decoded PCM sample data from the
|
||||||
|
* stream's current position, regardless of its type - dispatches to
|
||||||
|
* audioStreamPcmRead() or audioStreamMp3Read(). See either for the exact
|
||||||
|
* short-read-at-end-of-stream contract, which is identical for both.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to read from.
|
||||||
|
* @param buffer Destination buffer, sized for at least frameCount frames.
|
||||||
|
* @param frameCount Maximum number of frames to read.
|
||||||
|
* @param outFramesRead Set to the number of frames actually read.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamRead(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins or resumes playback of the given audio stream.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to play.
|
||||||
|
*/
|
||||||
|
void audioStreamPlay(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses playback of the given audio stream. Playback position is retained,
|
||||||
|
* so audioStreamPlay() resumes from the same point.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to pause.
|
||||||
|
*/
|
||||||
|
void audioStreamPause(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeks the given audio stream to the given position, in seconds, wrapping
|
||||||
|
* into range if the position is outside [0, duration). Takes effect on the
|
||||||
|
* next audioStreamUpdate() call for platforms that re-buffer per frame
|
||||||
|
* (e.g. Linux); on PSP, which manages an entire playback pass on its own
|
||||||
|
* feeder thread once started, a seek only takes effect the next time the
|
||||||
|
* stream begins playing from a stopped state, not instantaneously mid-pass.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to seek.
|
||||||
|
* @param position The new playback position, in seconds.
|
||||||
|
*/
|
||||||
|
void audioStreamSetPosition(audiostream_t *stream, const float_t position);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the loop region for the given audio stream: once playback reaches
|
||||||
|
* loopStart, it jumps back to loopTo instead of continuing (or stopping,
|
||||||
|
* if not looping). Has no effect unless looping is also enabled via
|
||||||
|
* audioStreamSetLooping().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @param loopStart Where the loop segment ends, in seconds, or -1 to loop
|
||||||
|
* the whole stream (the default).
|
||||||
|
* @param loopTo Where the loop segment starts, in seconds.
|
||||||
|
*/
|
||||||
|
void audioStreamSetLoopPoints(
|
||||||
|
audiostream_t *stream,
|
||||||
|
const float_t loopStart,
|
||||||
|
const float_t loopTo
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes normalized left/right pan factors (0..1) for the given audio
|
||||||
|
* stream's directionality. Callers multiply these by their own
|
||||||
|
* platform-specific base volume representation.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to read directionality from.
|
||||||
|
* @param outLeft Set to the left channel's pan factor.
|
||||||
|
* @param outRight Set to the right channel's pan factor.
|
||||||
|
*/
|
||||||
|
void audioStreamGetPanFactors(
|
||||||
|
const audiostream_t *stream,
|
||||||
|
float_t *outLeft,
|
||||||
|
float_t *outRight
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the stop frame for a playback pass starting at startFrame: the
|
||||||
|
* current loop segment's end (stream->loopStart, converted to frames and
|
||||||
|
* clamped to totalFrames) if looping is enabled and a loop start is set,
|
||||||
|
* or totalFrames itself otherwise - including when startFrame has already
|
||||||
|
* reached or passed the computed loop end (e.g. a seek landing in an
|
||||||
|
* outro past the loop point), in which case this pass plays out to the
|
||||||
|
* true end of the stream once instead of only the loop segment.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to compute this for.
|
||||||
|
* @param startFrame This pass's starting frame offset.
|
||||||
|
* @param totalFrames The stream's total decoded frame count
|
||||||
|
* (audioStreamGetTotalFrames()).
|
||||||
|
* @return This pass's stop frame offset.
|
||||||
|
*/
|
||||||
|
size_t audioStreamComputeEndFrame(
|
||||||
|
const audiostream_t *stream,
|
||||||
|
const size_t startFrame,
|
||||||
|
const size_t totalFrames
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the playback volume of the given audio stream.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @param volume The new volume, from 0.0 (silent) to 1.0 (loudest).
|
||||||
|
*/
|
||||||
|
void audioStreamSetVolume(audiostream_t *stream, const float_t volume);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the stereo directionality (panning) of the given audio stream.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @param directionality The new panning value, from AUDIO_STREAM_LEFT to
|
||||||
|
* AUDIO_STREAM_RIGHT.
|
||||||
|
*/
|
||||||
|
void audioStreamSetDirectionality(
|
||||||
|
audiostream_t *stream,
|
||||||
|
const float_t directionality
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the given audio stream loops back to loopTo (or the start
|
||||||
|
* of the buffer, by default - see audioStreamSetLoopPoints()) when it
|
||||||
|
* reaches loopStart (or the end of the buffer), rather than stopping and
|
||||||
|
* firing onEnd.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @param looping Whether the stream should loop.
|
||||||
|
*/
|
||||||
|
void audioStreamSetLooping(audiostream_t *stream, const bool_t looping);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the maximum number of times the given audio stream is allowed to
|
||||||
|
* loop before stopping (firing onEnd) instead of restarting again. Has no
|
||||||
|
* effect unless looping is also enabled via audioStreamSetLooping(). Resets
|
||||||
|
* the stream's current loop-restart progress back to 0, so the stream gets
|
||||||
|
* the full count from this call onward.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @param loopLimit Maximum number of loop restarts, or 0 (the default) to
|
||||||
|
* loop forever.
|
||||||
|
*/
|
||||||
|
void audioStreamSetLoopLimit(audiostream_t *stream, const uint32_t loopLimit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the given audio stream, decoding new data and advancing playback
|
||||||
|
* as needed. Should be called every frame for every active stream.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to update.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamUpdate(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the audio stream, stopping playback and releasing any resources
|
||||||
|
* associated with it.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to dispose.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamDispose(audiostream_t *stream);
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audiostreammp3.h"
|
||||||
|
#include "audiostream.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
|
||||||
|
errorret_t audioStreamMp3Init(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(stream->asset, "Stream must have an asset assigned.");
|
||||||
|
assertTrue(
|
||||||
|
stream->asset->type == ASSET_LOADER_TYPE_MP3,
|
||||||
|
"Asset is not an MP3 file."
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetmp3file_t *mp3 = &stream->asset->data.mp3;
|
||||||
|
|
||||||
|
stream->type = AUDIO_STREAM_TYPE_MP3;
|
||||||
|
stream->sampleRate = mp3->sampleRate;
|
||||||
|
stream->channels = mp3->channels;
|
||||||
|
stream->duration = (float_t) mp3->totalFrames / (float_t) mp3->sampleRate;
|
||||||
|
|
||||||
|
stream->mp3.pendingFrames = 0;
|
||||||
|
stream->mp3.pendingPosition = 0;
|
||||||
|
stream->mp3.position = 0;
|
||||||
|
stream->mp3.totalFrames = mp3->totalFrames;
|
||||||
|
|
||||||
|
// Each stream opens its own independent handle to the same underlying
|
||||||
|
// asset file - see audiostreampcm.c's audioStreamPcmInit() for why.
|
||||||
|
errorChain(assetFileInit(
|
||||||
|
&stream->mp3.file, stream->asset->name, NULL, NULL
|
||||||
|
));
|
||||||
|
errorChain(assetFileOpen(&stream->mp3.file));
|
||||||
|
errorChain(assetFileRead(&stream->mp3.file, NULL, mp3->dataOffset));
|
||||||
|
|
||||||
|
errorChain(audioStreamMp3DecoderInit(stream));
|
||||||
|
|
||||||
|
errorChain(audioStreamPlatformInit(stream));
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamMp3Dispose(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
||||||
|
|
||||||
|
errorChain(audioStreamMp3DecoderDispose(stream));
|
||||||
|
errorChain(assetFileClose(&stream->mp3.file));
|
||||||
|
errorChain(assetFileDispose(&stream->mp3.file));
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t audioStreamMp3GetTotalFrames(const audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
||||||
|
|
||||||
|
return stream->mp3.totalFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamMp3Seek(audiostream_t *stream, const size_t frame) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
||||||
|
|
||||||
|
if(frame == stream->mp3.position) {
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every seek - forward or backward - restarts decoding from the very
|
||||||
|
// start of the compressed stream and discards frames until reaching the
|
||||||
|
// target; see this file's own header comment for why MP3 has no cheaper
|
||||||
|
// option, unlike audioStreamPcmSeek()'s direct byte-offset seek.
|
||||||
|
errorChain(audioStreamMp3DecoderRewind(stream));
|
||||||
|
stream->mp3.position = 0;
|
||||||
|
stream->mp3.pendingFrames = 0;
|
||||||
|
stream->mp3.pendingPosition = 0;
|
||||||
|
|
||||||
|
const size_t channels = stream->channels;
|
||||||
|
int16_t discard[AUDIO_MP3_MAX_SAMPLES_PER_FRAME];
|
||||||
|
|
||||||
|
while(stream->mp3.position < frame) {
|
||||||
|
size_t decodedFrames = 0;
|
||||||
|
errorChain(
|
||||||
|
audioStreamMp3DecoderDecodeFrame(stream, discard, &decodedFrames)
|
||||||
|
);
|
||||||
|
if(decodedFrames == 0) {
|
||||||
|
// Ran out of stream before reaching the target - clamp rather than
|
||||||
|
// erroring, same tolerance audioStreamPcmRead() has for a
|
||||||
|
// shorter-than-declared asset.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t framesNeeded = frame - stream->mp3.position;
|
||||||
|
if(decodedFrames <= framesNeeded) {
|
||||||
|
stream->mp3.position += decodedFrames;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This frame overshoots the target - keep the excess as pending so
|
||||||
|
// the next Read() starts exactly at `frame` instead of skipping past
|
||||||
|
// it.
|
||||||
|
const size_t keepFrames = decodedFrames - framesNeeded;
|
||||||
|
memoryMove(
|
||||||
|
discard, discard + (framesNeeded * channels),
|
||||||
|
keepFrames * channels * sizeof(int16_t)
|
||||||
|
);
|
||||||
|
memoryCopy(
|
||||||
|
stream->mp3.pending, discard, keepFrames * channels * sizeof(int16_t)
|
||||||
|
);
|
||||||
|
stream->mp3.pendingFrames = keepFrames;
|
||||||
|
stream->mp3.pendingPosition = 0;
|
||||||
|
stream->mp3.position = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamMp3Read(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(buffer, "Buffer cannot be NULL.");
|
||||||
|
assertNotNull(outFramesRead, "outFramesRead cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_MP3, "Stream is not MP3.");
|
||||||
|
|
||||||
|
const size_t channels = stream->channels;
|
||||||
|
size_t framesWritten = 0;
|
||||||
|
|
||||||
|
while(framesWritten < frameCount) {
|
||||||
|
if(stream->mp3.pendingPosition >= stream->mp3.pendingFrames) {
|
||||||
|
size_t decodedFrames = 0;
|
||||||
|
errorChain(audioStreamMp3DecoderDecodeFrame(
|
||||||
|
stream, stream->mp3.pending, &decodedFrames
|
||||||
|
));
|
||||||
|
stream->mp3.pendingFrames = decodedFrames;
|
||||||
|
stream->mp3.pendingPosition = 0;
|
||||||
|
if(decodedFrames == 0) break; // Genuine end of stream.
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t framesAvailable =
|
||||||
|
stream->mp3.pendingFrames - stream->mp3.pendingPosition;
|
||||||
|
const size_t framesToCopy =
|
||||||
|
mathMin(frameCount - framesWritten, framesAvailable);
|
||||||
|
|
||||||
|
memoryCopy(
|
||||||
|
buffer + (framesWritten * channels),
|
||||||
|
stream->mp3.pending + (stream->mp3.pendingPosition * channels),
|
||||||
|
framesToCopy * channels * sizeof(int16_t)
|
||||||
|
);
|
||||||
|
|
||||||
|
stream->mp3.pendingPosition += framesToCopy;
|
||||||
|
framesWritten += framesToCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream->mp3.position += framesWritten;
|
||||||
|
*outFramesRead = framesWritten;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "error/error.h"
|
||||||
|
#include "asset/assetfile.h"
|
||||||
|
#include "audio/audiostreammp3decoder.h"
|
||||||
|
|
||||||
|
typedef struct audiostream_s audiostream_t;
|
||||||
|
|
||||||
|
// How many interleaved PCM samples (not frames - samples, i.e. frames *
|
||||||
|
// channels) a single audioStreamMp3DecoderDecodeFrame() call is ever
|
||||||
|
// allowed to produce. Sizes the pending-sample buffer below, which every
|
||||||
|
// decoder backend writes directly into - this must be sized to the
|
||||||
|
// largest of them, not just "one MPEG frame":
|
||||||
|
// - libmad (software, Linux/Dolphin): always exactly one frame, 1152
|
||||||
|
// samples/channel for MPEG-1 Layer III, doubled here for stereo.
|
||||||
|
// - sceMp3 (hardware, PSP): its own pcmBuf is provisioned at double that
|
||||||
|
// (see AUDIO_MP3_PSP_PCM_BUF_SIZE) - i.e. sceMp3Decode() can
|
||||||
|
// legitimately hand back more than one frame's worth in a single call.
|
||||||
|
// Sizing this for only one frame silently overflowed stream->mp3.pending
|
||||||
|
// on real hardware whenever that happened - confirmed as the cause of
|
||||||
|
// very intermittent audio corruption/clicking, since it only bit when
|
||||||
|
// sceMp3 actually returned the larger amount.
|
||||||
|
// So this covers the larger (PSP) case; libmad's decode is safely well
|
||||||
|
// within it.
|
||||||
|
#define AUDIO_MP3_MAX_SAMPLES_PER_FRAME (1152 * 2 * 2)
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
// This stream's own private handle into its asset's underlying file -
|
||||||
|
// see audiostreampcm_t.file's own comment for why this isn't shared
|
||||||
|
// across streams playing the same asset. Holds compressed MP3 bytes;
|
||||||
|
// the decoder backend (audiostreammp3decoder.h) reads from it directly.
|
||||||
|
assetfile_t file;
|
||||||
|
|
||||||
|
// Opaque per-platform decoder state - a reserved sceMp3 handle plus its
|
||||||
|
// buffers on PSP, or libmad's mad_stream/mad_frame/mad_synth state plus a
|
||||||
|
// sliding compressed-byte window on the libmad-based software backend
|
||||||
|
// (Linux/Dolphin). Defined by whichever audiostreammp3decoder.h is
|
||||||
|
// actually visible when this file is compiled - see that header's own
|
||||||
|
// comment.
|
||||||
|
audiostreammp3decoder_t decoder;
|
||||||
|
|
||||||
|
// One decoded MPEG frame's worth of PCM, held here across Read() calls
|
||||||
|
// since a frame (576 or 1152 samples/channel) rarely lines up exactly
|
||||||
|
// with whatever frameCount a caller asks for. pendingPosition marks how
|
||||||
|
// many of the first pendingFrames frames have already been consumed.
|
||||||
|
int16_t pending[AUDIO_MP3_MAX_SAMPLES_PER_FRAME];
|
||||||
|
size_t pendingFrames;
|
||||||
|
size_t pendingPosition;
|
||||||
|
|
||||||
|
// Current logical PCM frame position, relative to the start of the
|
||||||
|
// decoded stream - decoding is push-forward-only (see
|
||||||
|
// audioStreamMp3Seek()'s own comment on why a backward seek re-decodes
|
||||||
|
// from the start rather than truly random-accessing).
|
||||||
|
size_t position;
|
||||||
|
|
||||||
|
// Total decoded PCM frame count for the whole clip - copied from the
|
||||||
|
// asset's parsed metadata at Init (see assetmp3file_t.totalFrames),
|
||||||
|
// exact if a Xing/Info header was present, otherwise an estimate.
|
||||||
|
size_t totalFrames;
|
||||||
|
} audiostreammp3_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the given MP3 audio stream from its already-assigned asset
|
||||||
|
* (see audiostream_t.asset - set by audioStreamInit(), which is what
|
||||||
|
* should be calling this, not application code directly): opens this
|
||||||
|
* stream's own private file handle onto the asset's compressed MPEG data,
|
||||||
|
* and initializes the platform decoder backend
|
||||||
|
* (audioStreamMp3DecoderInit()).
|
||||||
|
*
|
||||||
|
* Must be called after stream->asset is set and before audioStreamPlay().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to configure.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamMp3Init(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the given MP3 audio stream's decoder backend and private file
|
||||||
|
* handle.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to dispose.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamMp3Dispose(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of frames (one sample per channel) available in
|
||||||
|
* the stream's underlying MP3 data - see assetmp3file_t.totalFrames's own
|
||||||
|
* comment on why this is sometimes an estimate rather than an exact count,
|
||||||
|
* unlike audioStreamPcmGetTotalFrames().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to query. Must be AUDIO_STREAM_TYPE_MP3.
|
||||||
|
* @return The total number of frames available.
|
||||||
|
*/
|
||||||
|
size_t audioStreamMp3GetTotalFrames(const audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeks the stream's decode position to the given frame offset. Unlike
|
||||||
|
* audioStreamPcmSeek(), this is never cheap: MPEG frames aren't
|
||||||
|
* independently decodable (each one's bit reservoir can depend on data
|
||||||
|
* carried over from prior frames), so there's no equivalent of PCM's
|
||||||
|
* direct byte-offset seek - every call, forward or backward, rewinds the
|
||||||
|
* decoder to the very start of the compressed stream and decodes (and
|
||||||
|
* discards) frames until reaching the target. Acceptable for this engine's
|
||||||
|
* actual use (looping background music, where loopTo is typically near
|
||||||
|
* the start anyway), but a real, non-constant cost for a seek deep into a
|
||||||
|
* long track.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_MP3.
|
||||||
|
* @param frame Frame offset to seek to, relative to the start of the
|
||||||
|
* decoded stream.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamMp3Seek(audiostream_t *stream, const size_t frame);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads up to frameCount frames of PCM sample data from the stream's
|
||||||
|
* current position, advancing it by however many frames were actually
|
||||||
|
* read. Reads fewer than frameCount (down to zero) once the underlying
|
||||||
|
* MP3 data is exhausted, rather than erroring - same contract as
|
||||||
|
* audioStreamPcmRead().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to read from. Must be AUDIO_STREAM_TYPE_MP3.
|
||||||
|
* @param buffer Destination buffer, sized for at least frameCount frames.
|
||||||
|
* @param frameCount Maximum number of frames to read.
|
||||||
|
* @param outFramesRead Set to the number of frames actually read.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamMp3Read(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
);
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "audiostreampcm.h"
|
||||||
|
#include "audiostream.h"
|
||||||
|
#include "assert/assert.h"
|
||||||
|
#include "util/math.h"
|
||||||
|
#include "util/memory.h"
|
||||||
|
#include "util/endian.h"
|
||||||
|
|
||||||
|
errorret_t audioStreamPcmInit(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(stream->asset, "Stream must have an asset assigned.");
|
||||||
|
assertTrue(
|
||||||
|
stream->asset->type == ASSET_LOADER_TYPE_WAV,
|
||||||
|
"Asset is not a WAV file."
|
||||||
|
);
|
||||||
|
|
||||||
|
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||||
|
|
||||||
|
stream->type = AUDIO_STREAM_TYPE_PCM;
|
||||||
|
stream->sampleRate = wav->sampleRate;
|
||||||
|
stream->channels = wav->channels;
|
||||||
|
stream->duration = (
|
||||||
|
(float_t) audioStreamPcmGetTotalFrames(stream) /
|
||||||
|
(float_t) wav->sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each stream opens its own independent handle to the same underlying
|
||||||
|
// asset file, rather than sharing a single handle on the asset entry -
|
||||||
|
// multiple streams playing the same asset concurrently (e.g. two
|
||||||
|
// simultaneous plays of the same sound effect) would otherwise fight
|
||||||
|
// over one shared read position. This isn't the final answer for that
|
||||||
|
// (a shared decode/cache layer would scale better than N independent
|
||||||
|
// decompression streams of the same data), but it's a correct one for
|
||||||
|
// now - a problem to revisit once it actually matters.
|
||||||
|
errorChain(assetFileInit(
|
||||||
|
&stream->pcm.file, stream->asset->name, NULL, NULL
|
||||||
|
));
|
||||||
|
errorChain(assetFileOpen(&stream->pcm.file));
|
||||||
|
errorChain(assetFileRead(&stream->pcm.file, NULL, wav->dataOffset));
|
||||||
|
|
||||||
|
errorChain(audioStreamPlatformInit(stream));
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamPcmDispose(audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||||
|
|
||||||
|
errorChain(assetFileClose(&stream->pcm.file));
|
||||||
|
errorChain(assetFileDispose(&stream->pcm.file));
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||||
|
|
||||||
|
const assetwavfile_t *wav = &stream->asset->data.wav;
|
||||||
|
const size_t sourceFrameSize = (
|
||||||
|
stream->channels * (wav->bitsPerSample / 8)
|
||||||
|
);
|
||||||
|
return wav->dataSize / sourceFrameSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||||
|
|
||||||
|
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||||
|
const size_t sourceFrameSize = (
|
||||||
|
stream->channels * (wav->bitsPerSample / 8)
|
||||||
|
);
|
||||||
|
const size_t targetByte = wav->dataOffset + (frame * sourceFrameSize);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
frame * sourceFrameSize <= wav->dataSize,
|
||||||
|
"Seek frame is beyond the end of the PCM data."
|
||||||
|
);
|
||||||
|
|
||||||
|
const size_t currentByte = (size_t) stream->pcm.file.position;
|
||||||
|
if(targetByte < currentByte) {
|
||||||
|
// A rewind is needed to move the read cursor earlier once it's
|
||||||
|
// already advanced past a point - assetFileRewind() takes the cheap
|
||||||
|
// in-place seek path since every dusk.dsk entry is stored
|
||||||
|
// uncompressed.
|
||||||
|
errorChain(assetFileRewind(&stream->pcm.file));
|
||||||
|
errorChain(assetFileRead(&stream->pcm.file, NULL, targetByte));
|
||||||
|
} else if(targetByte > currentByte) {
|
||||||
|
errorChain(assetFileRead(
|
||||||
|
&stream->pcm.file, NULL, targetByte - currentByte
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorret_t audioStreamPcmRead(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
) {
|
||||||
|
assertNotNull(stream, "Stream cannot be NULL.");
|
||||||
|
assertNotNull(buffer, "Buffer cannot be NULL.");
|
||||||
|
assertNotNull(outFramesRead, "outFramesRead cannot be NULL.");
|
||||||
|
assertTrue(stream->type == AUDIO_STREAM_TYPE_PCM, "Stream is not PCM.");
|
||||||
|
|
||||||
|
assetwavfile_t *wav = &stream->asset->data.wav;
|
||||||
|
const size_t channels = stream->channels;
|
||||||
|
const size_t sourceSampleBytes = wav->bitsPerSample / 8;
|
||||||
|
const size_t sourceFrameSize = channels * sourceSampleBytes;
|
||||||
|
const size_t dataEndByte = wav->dataOffset + wav->dataSize;
|
||||||
|
const size_t currentByte = (size_t) stream->pcm.file.position;
|
||||||
|
|
||||||
|
const size_t bytesAvailable = (
|
||||||
|
currentByte < dataEndByte ? dataEndByte - currentByte : 0
|
||||||
|
);
|
||||||
|
const size_t framesAvailable = bytesAvailable / sourceFrameSize;
|
||||||
|
const size_t framesToRead = mathMin(frameCount, framesAvailable);
|
||||||
|
|
||||||
|
if(framesToRead == 0) {
|
||||||
|
*outFramesRead = 0;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(sourceSampleBytes == sizeof(int16_t)) {
|
||||||
|
// Output format already matches the source in width, but WAV samples
|
||||||
|
// are always little-endian per the format spec regardless of host -
|
||||||
|
// read straight through, then fix up byte order in place. A no-op on
|
||||||
|
// every little-endian platform (endianLittleToHost16() compiles down
|
||||||
|
// to returning its argument unchanged there) - only actually swaps
|
||||||
|
// anything on a big-endian host (Dolphin/GameCube/Wii).
|
||||||
|
errorChain(assetFileRead(
|
||||||
|
&stream->pcm.file, buffer, framesToRead * sourceFrameSize
|
||||||
|
));
|
||||||
|
const size_t sampleCount = framesToRead * channels;
|
||||||
|
for(size_t i = 0; i < sampleCount; i++) {
|
||||||
|
buffer[i] = (int16_t) endianLittleToHost16((uint16_t) buffer[i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 24-bit source (the only other width assetWavParseHeader() accepts) -
|
||||||
|
// read the raw 3-byte samples into a scratch buffer, then truncate
|
||||||
|
// each down to 16-bit, since none of PSP/Dolphin's audio hardware (or
|
||||||
|
// SDL2 on Linux) accepts anything wider - see assetwavfile_t's own
|
||||||
|
// comment on bitsPerSample.
|
||||||
|
assertTrue(sourceSampleBytes == 3, "Unsupported PCM sample width.");
|
||||||
|
|
||||||
|
const size_t rawBytes = framesToRead * sourceFrameSize;
|
||||||
|
uint8_t *raw = memoryAllocate(rawBytes);
|
||||||
|
errorret_t ret = assetFileRead(&stream->pcm.file, raw, rawBytes);
|
||||||
|
if(errorIsNotOk(ret)) {
|
||||||
|
memoryFree(raw);
|
||||||
|
errorChain(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24-bit PCM samples are little-endian two's complement - the top two
|
||||||
|
// bytes of each (indices 1 and 2) already form that same value
|
||||||
|
// truncated to 16-bit (equivalent to an arithmetic right-shift by 8
|
||||||
|
// bits, which preserves the sign correctly since byte 2 carries it).
|
||||||
|
const size_t sampleCount = framesToRead * channels;
|
||||||
|
for(size_t i = 0; i < sampleCount; i++) {
|
||||||
|
const uint8_t *sample = raw + (i * 3);
|
||||||
|
buffer[i] = (int16_t) (sample[1] | (sample[2] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryFree(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
*outFramesRead = framesToRead;
|
||||||
|
errorOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 Dominic Masters
|
||||||
|
*
|
||||||
|
* This software is released under the MIT License.
|
||||||
|
* https://opensource.org/licenses/MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
#include "error/error.h"
|
||||||
|
#include "asset/assetfile.h"
|
||||||
|
|
||||||
|
typedef struct audiostream_s audiostream_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
// This stream's own private handle into its asset's underlying file -
|
||||||
|
// deliberately not shared with any other stream reading the same asset
|
||||||
|
// (see audioStreamPcmInit's own comment for why). Its read cursor is
|
||||||
|
// what audioStreamPcmRead()/audioStreamPcmSeek() operate on.
|
||||||
|
assetfile_t file;
|
||||||
|
} audiostreampcm_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the given PCM audio stream from its already-assigned asset
|
||||||
|
* (see audiostream_t.asset - set by audioStreamInit(), which is what
|
||||||
|
* should be calling this, not application code directly) rather than
|
||||||
|
* taking raw decoded data: sampleRate/channels/duration are read from the
|
||||||
|
* asset's parsed WAV header, and a private file handle is opened for
|
||||||
|
* reading sample data on demand as playback consumes it (see
|
||||||
|
* audioStreamPcmRead()) - the PCM data itself is never read into memory
|
||||||
|
* all at once, so this works the same regardless of how long the
|
||||||
|
* underlying clip is.
|
||||||
|
*
|
||||||
|
* Must be called after stream->asset is set and before audioStreamPlay().
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to configure.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamPcmInit(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the given PCM audio stream's own private file handle.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to dispose.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamPcmDispose(audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of frames (one sample per channel) available
|
||||||
|
* in the stream's underlying PCM data - NOT simply the asset's declared
|
||||||
|
* byte size divided by a 16-bit frame size, since the source data isn't
|
||||||
|
* always 16-bit even though audioStreamPcmRead() always produces 16-bit
|
||||||
|
* output (see assetwavfile_t.bitsPerSample). Platform backends should use
|
||||||
|
* this rather than computing frame counts from dataSize themselves.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to query. Must be AUDIO_STREAM_TYPE_PCM.
|
||||||
|
* @return The total number of frames available.
|
||||||
|
*/
|
||||||
|
size_t audioStreamPcmGetTotalFrames(const audiostream_t *stream);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeks the stream's private read cursor to the given frame offset
|
||||||
|
* (relative to the start of the PCM data - the same units as
|
||||||
|
* audiostream_t's startFrame/loopStart/loopTo, once converted from
|
||||||
|
* seconds). Seeking backward re-reads from the start of the underlying
|
||||||
|
* asset file (see assetFileRewind()), which is a cheap in-place seek
|
||||||
|
* since every dusk.dsk entry is stored uncompressed.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to seek. Must be AUDIO_STREAM_TYPE_PCM.
|
||||||
|
* @param frame Frame offset to seek to, relative to the start of the PCM
|
||||||
|
* data.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamPcmSeek(audiostream_t *stream, const size_t frame);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads up to frameCount frames of PCM sample data from the stream's
|
||||||
|
* current read position, advancing it by however many frames were
|
||||||
|
* actually read. Reads fewer than frameCount (down to zero) once the
|
||||||
|
* underlying asset's PCM data is exhausted, rather than erroring - it's
|
||||||
|
* up to the caller (platform code, which already knows about loop points)
|
||||||
|
* to request no more than what it wants read from within the current
|
||||||
|
* loop segment or the true end of the clip.
|
||||||
|
*
|
||||||
|
* @param stream The audio stream to read from. Must be AUDIO_STREAM_TYPE_PCM.
|
||||||
|
* @param buffer Destination buffer, sized for at least frameCount frames.
|
||||||
|
* @param frameCount Maximum number of frames to read.
|
||||||
|
* @param outFramesRead Set to the number of frames actually read.
|
||||||
|
* @return Error indicating success or failure.
|
||||||
|
*/
|
||||||
|
errorret_t audioStreamPcmRead(
|
||||||
|
audiostream_t *stream,
|
||||||
|
int16_t *buffer,
|
||||||
|
const size_t frameCount,
|
||||||
|
size_t *outFramesRead
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user