Repository navigation
Expand file tree
/
Copy pathprofile.zig
More file actions
386 lines (352 loc) · 18.5 KB
/
Copy pathprofile.zig
File metadata and controls
386 lines (352 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
//! Replacements for Windows' `GetPrivateProfileIntA` and `GetPrivateProfileStringA`, which the game
//! uses to read its settings from `starlancer.ini`. An ini file consists of lines of text:
//! `[section]` starts a section, and `key=value` sets a value. Section and key names are not
//! case-sensitive, and values have surrounding spaces and one pair of quotes removed.
const std = @import("std");
const log = std.log.scoped(.settings);
const Allocator = std.mem.Allocator;
const Io = std.Io;
const files = @import("files.zig");
/// The game's settings file, in its folder, which it names in lower case (`settings_path`,
/// `0x005D5644`, from `%sstarlancer.ini` at `0x005099B0`).
pub const settings_name = "starlancer.ini";
pub const Profile = struct {
text: []const u8,
/// An empty profile, used when the file is missing: every read returns its default.
pub const empty: Profile = .{ .text = "" };
/// The settings file in the game's folder `dir`, found in any case and read into `arena`.
/// It is empty when the file is missing or can't be read, so that every setting keeps its
/// default. When the file exists but can't be read, the log says so.
pub fn read(io: Io, arena: Allocator, dir: Io.Dir) Profile {
const text = files.readFile(io, arena, dir, settings_name, .limited(files.max_file_size)) catch |err| {
log.warn("can't read {s}, so every setting keeps its default: {s}", .{ settings_name, @errorName(err) });
return .empty;
} orelse return .empty;
return .{ .text = text };
}
/// The value of `key` in `section`, or null if there is none.
pub fn value(profile: Profile, section: []const u8, key: []const u8) ?[]const u8 {
const line = locate(profile.text, section, key).line orelse return null;
const text = profile.text[line.start..line.end];
const equals = std.mem.findScalar(u8, text, '=').?;
const found = std.mem.trim(u8, text[equals + 1 ..], " \t");
if (found.len >= 2 and (found[0] == '"' or found[0] == '\'') and found[found.len - 1] == found[0]) {
return found[1 .. found.len - 1];
}
return found;
}
/// `WritePrivateProfileStringA`: the text with `key` in `section` set to `written`. The key's
/// line is replaced where the section has one; else the key goes after the section's last
/// line, or the section and the key at the end. Lines end as the file's first one does, or
/// with a carriage return and a line feed, as Windows writes them.
pub fn write(profile: Profile, gpa: Allocator, section: []const u8, key: []const u8, written: []const u8) Allocator.Error![]u8 {
const text = profile.text;
const location = locate(text, section, key);
const newline: []const u8 = if (std.mem.findScalar(u8, text, '\n')) |at|
(if (at > 0 and text[at - 1] == '\r') "\r\n" else "\n")
else
"\r\n";
if (location.line) |line| {
return std.mem.concat(gpa, u8, &.{ text[0..line.start], key, "=", written, text[line.end..] });
}
if (location.section_end) |end| {
return std.mem.concat(gpa, u8, &.{ text[0..end], newline, key, "=", written, text[end..] });
}
const separate: []const u8 = if (text.len > 0 and text[text.len - 1] != '\n') newline else "";
return std.mem.concat(gpa, u8, &.{ text, separate, "[", section, "]", newline, key, "=", written, newline });
}
/// `WritePrivateProfileStringA` with no value, which deletes the key: the text without `key`'s
/// line in `section`, its line ending with it; null where the section has no such key.
pub fn remove(profile: Profile, gpa: Allocator, section: []const u8, key: []const u8) Allocator.Error!?[]u8 {
const text = profile.text;
const line = locate(text, section, key).line orelse return null;
var end = line.end;
if (end < text.len and text[end] == '\r') end += 1;
if (end < text.len and text[end] == '\n') end += 1;
return try std.mem.concat(gpa, u8, &.{ text[0..line.start], text[end..] });
}
/// Where `key` of `section` stands in `text`.
const Location = struct {
/// The key's line, without its line ending, where the section has it.
line: ?Span = null,
/// Where a line added to the section goes: the end of its last line that isn't blank.
section_end: ?usize = null,
const Span = struct { start: usize, end: usize };
};
/// Finds `key` in the first section named `section`, both without regard to case, as
/// Windows does.
fn locate(text: []const u8, section: []const u8, key: []const u8) Location {
var location: Location = .{};
var in_section = false;
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.index) |start| {
const raw = lines.next().?;
const end = start + std.mem.trimEnd(u8, raw, "\r").len;
const line = std.mem.trim(u8, text[start..end], " \t");
if (line.len == 0) continue;
if (line[0] == '[') {
const close = std.mem.findScalar(u8, line, ']') orelse continue;
// Only the first section of the name counts.
if (in_section) return location;
in_section = std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[1..close], " \t"), section);
if (in_section) location.section_end = end;
continue;
}
if (!in_section) continue;
location.section_end = end;
const equals = std.mem.findScalar(u8, line, '=') orelse continue;
if (location.line == null and std.ascii.eqlIgnoreCase(std.mem.trimEnd(u8, line[0..equals], " \t"), key)) {
location.line = .{ .start = start, .end = end };
}
}
return location;
}
/// Whether the file has a section called `section`, ignoring case, even an empty one.
pub fn hasSection(profile: Profile, section: []const u8) bool {
var lines = std.mem.splitScalar(u8, profile.text, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0 or line[0] != '[') continue;
const close = std.mem.findScalar(u8, line, ']') orelse continue;
if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[1..close], " \t"), section)) return true;
}
return false;
}
/// `GetPrivateProfileStringA` with no key name, which lists a section's keys: iterates over the
/// key names in the first section called `section`, ignoring case.
pub fn keys(profile: Profile, section: []const u8) Keys {
return .{ .lines = std.mem.splitScalar(u8, profile.text, '\n'), .section = section };
}
pub const Keys = struct {
lines: std.mem.SplitIterator(u8, .scalar),
section: []const u8,
place: enum { before, inside, past } = .before,
pub fn next(listed: *Keys) ?[]const u8 {
while (listed.place != .past) {
const raw = listed.lines.next() orelse return null;
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
if (line[0] == '[') {
const close = std.mem.findScalar(u8, line, ']') orelse continue;
// Only the first section of the name counts.
if (listed.place == .inside) {
listed.place = .past;
return null;
}
if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[1..close], " \t"), listed.section)) listed.place = .inside;
continue;
}
if (listed.place != .inside) continue;
const equals = std.mem.findScalar(u8, line, '=') orelse continue;
return std.mem.trimEnd(u8, line[0..equals], " \t");
}
return null;
}
};
/// `GetPrivateProfileStringA`: the value, or `default` if there is none, truncated to `size - 1`
/// bytes to fit the game's buffer.
pub fn string(profile: Profile, section: []const u8, key: []const u8, default: []const u8, size: usize) []const u8 {
const found = profile.value(section, key) orelse default;
return found[0..@min(found.len, size -| 1)];
}
/// `GetPrivateProfileIntA`: the number formed by the value's leading decimal digits (0 if it
/// doesn't start with a digit), or `default` if there is no value.
pub fn int(profile: Profile, section: []const u8, key: []const u8, default: u32) u32 {
const found = profile.value(section, key) orelse return default;
var number: u32 = 0;
for (found) |c| {
if (!std.ascii.isDigit(c)) break;
number = number *% 10 +% (c - '0');
}
return number;
}
};
/// The settings file while the game runs: its text, which the game's writes change, and whether it
/// has changed since it was last saved. Every version of the text stays in `arena`, as the game
/// writes its settings seldom.
pub const File = struct {
arena: Allocator,
profile: Profile,
changed: bool = false,
/// `WritePrivateProfileStringA`, of the file.
pub fn write(file: *File, section: []const u8, key: []const u8, written: []const u8) Allocator.Error!void {
file.profile.text = try file.profile.write(file.arena, section, key, written);
file.changed = true;
}
/// Writes `number` in decimal, as the game's `%d` does.
pub fn writeInt(file: *File, section: []const u8, key: []const u8, number: i64) Allocator.Error!void {
var buffer: [24]u8 = undefined;
const length = std.fmt.printInt(&buffer, number, 10, .lower, .{});
try file.write(section, key, buffer[0..length]);
}
/// Deletes `key` of `section`, where the file has it (`Profile.remove`).
pub fn remove(file: *File, section: []const u8, key: []const u8) Allocator.Error!void {
file.profile.text = try file.profile.remove(file.arena, section, key) orelse return;
file.changed = true;
}
/// Writes the settings to `settings_name` in the game's folder `dir`, if they changed since the
/// last save. OpenReliant's loops call it each pass, and once more on the way out, so a change
/// is saved however the game ends. A failure is logged once, and the change is written with the
/// next one.
pub fn save(file: *File, io: Io, dir: Io.Dir) void {
if (!file.changed) return;
file.changed = false;
files.writeFile(io, dir, settings_name, file.profile.text) catch |err|
log.warn("the settings can't be saved to {s}: {t}", .{ settings_name, err });
}
};
test "hasSection" {
const profile: Profile = .{ .text = "[Device]\r\nView=1\r\n[ OpenReliantMods ]\r\n" };
try std.testing.expect(profile.hasSection("device"));
// An empty section counts, and its name is found whatever the spaces around it.
try std.testing.expect(profile.hasSection("OpenReliantMods"));
try std.testing.expect(!profile.hasSection("Sound"));
try std.testing.expect(!Profile.empty.hasSection("Device"));
}
test "File.remove" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
var file: File = .{ .arena = arena.allocator(), .profile = .{ .text = "[Device]\r\nView=1\r\nGamma=100\r\n[Sound]\r\nView=2\r\n" } };
// The key's line goes with its ending, the other section's key of the name stays.
try file.remove("device", "VIEW");
try std.testing.expectEqualStrings("[Device]\r\nGamma=100\r\n[Sound]\r\nView=2\r\n", file.profile.text);
try std.testing.expect(file.changed);
// A key the section lacks leaves the file as it is.
file.changed = false;
try file.remove("Device", "View");
try std.testing.expect(!file.changed);
// The last line, without an ending.
file.profile = .{ .text = "[OpenReliant]\nFrameRate=60" };
try file.remove("OpenReliant", "FrameRate");
try std.testing.expectEqualStrings("[OpenReliant]\n", file.profile.text);
}
/// The C runtime's `atol`: skips spaces, reads an optional sign and then as many decimal digits as
/// there are. Returns 0 if there are none.
pub fn atol(text: []const u8) i32 {
var rest = std.mem.trimStart(u8, text, " \t");
var negative = false;
if (rest.len > 0 and (rest[0] == '-' or rest[0] == '+')) {
negative = rest[0] == '-';
rest = rest[1..];
}
var number: i32 = 0;
for (rest) |c| {
if (!std.ascii.isDigit(c)) break;
number = number *% 10 +% (c - '0');
}
return if (negative) -%number else number;
}
test Profile {
const profile: Profile = .{ .text =
\\; the game writes this file itself
\\[KeyConfig]
\\JoystickInvert = 0
\\FIRE LASERS=JOY BUTTON 3
\\Controller=2 ; trailing words
\\
\\[joyconfig]
\\fire lasers="JOY BUTTON 1"
\\Empty=
\\
};
try std.testing.expectEqualStrings("0", profile.value("KeyConfig", "JoystickInvert").?);
try std.testing.expectEqualStrings("JOY BUTTON 3", profile.value("keyconfig", "fire lasers").?);
try std.testing.expectEqualStrings("JOY BUTTON 1", profile.value("JoyConfig", "FIRE LASERS").?);
try std.testing.expectEqual(null, profile.value("JoyConfig", "JoystickInvert"));
try std.testing.expectEqual(0, profile.int("KeyConfig", "JoystickInvert", 1));
try std.testing.expectEqual(1, profile.int("KeyConfig", "HatEnable", 1));
try std.testing.expectEqual(2, profile.int("KeyConfig", "Controller", 0));
try std.testing.expectEqual(0, profile.int("JoyConfig", "Empty", 7));
try std.testing.expectEqualStrings("JOY", profile.string("JoyConfig", "Fire Lasers", "", 4));
try std.testing.expectEqualStrings("57", profile.string("JoyConfig", "Missing", "57", 0x80));
try std.testing.expectEqual(null, Profile.empty.value("KeyConfig", "Controller"));
}
test "Profile.read" {
const io = std.testing.io;
var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena_state.deinit();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Without the file, every setting keeps its default.
try std.testing.expectEqualStrings("", Profile.read(io, arena_state.allocator(), tmp.dir).text);
// The file is found in any case.
try tmp.dir.writeFile(io, .{ .sub_path = "STARLANCER.INI", .data = "[Device]\r\nView=1\r\n" });
try std.testing.expectEqual(1, Profile.read(io, arena_state.allocator(), tmp.dir).int("Device", "View", 0));
// A folder in the file's place can't be read, so every setting keeps its default.
var folder = std.testing.tmpDir(.{});
defer folder.cleanup();
try folder.dir.createDirPath(io, settings_name ++ "/inside");
try std.testing.expectEqual(0, Profile.read(io, arena_state.allocator(), folder.dir).int("Device", "View", 0));
}
test "Profile.write" {
const gpa = std.testing.allocator;
const profile: Profile = .{ .text = "[Sound]\r\nFxvolume=80\r\n\r\n[Device]\r\nView=1\r\n" };
// A key the section has keeps its place.
const replaced = try profile.write(gpa, "sound", "FXVOLUME", "64");
defer gpa.free(replaced);
try std.testing.expectEqualStrings("[Sound]\r\nFXVOLUME=64\r\n\r\n[Device]\r\nView=1\r\n", replaced);
// A new key goes after the section's last line, before the blank one.
const added = try profile.write(gpa, "Sound", "Musicvolume", "70");
defer gpa.free(added);
try std.testing.expectEqualStrings("[Sound]\r\nFxvolume=80\r\nMusicvolume=70\r\n\r\n[Device]\r\nView=1\r\n", added);
try std.testing.expectEqual(70, (Profile{ .text = added }).int("Sound", "Musicvolume", 0));
// A new section goes at the end, and a file of plain line feeds keeps them.
const sectioned = try (Profile{ .text = "[A]\nx=1" }).write(gpa, "Device", "gamma", "100");
defer gpa.free(sectioned);
try std.testing.expectEqualStrings("[A]\nx=1\n[Device]\ngamma=100\n", sectioned);
const fresh = try Profile.empty.write(gpa, "Device", "View", "2");
defer gpa.free(fresh);
try std.testing.expectEqualStrings("[Device]\r\nView=2\r\n", fresh);
// Only the first section of a name is read and written.
const twice: Profile = .{ .text = "[S]\na=1\n[S]\nb=2\n" };
try std.testing.expectEqual(null, twice.value("S", "b"));
const written = try twice.write(gpa, "S", "b", "3");
defer gpa.free(written);
try std.testing.expectEqualStrings("[S]\na=1\nb=3\n[S]\nb=2\n", written);
}
test "Profile.keys" {
const profile: Profile = .{ .text = "[Mod]\r\nName=x\r\n[scripts]\r\n Load = a.luau\r\n; no key\r\nGlobal=b.luau\r\n\r\n[Scripts]\r\nPlayer=c.luau\r\n" };
var listed = profile.keys("Scripts");
// Only the first section with the name counts, ignoring case. Keys are trimmed, and lines
// without a key are skipped.
try std.testing.expectEqualStrings("Load", listed.next().?);
try std.testing.expectEqualStrings("Global", listed.next().?);
try std.testing.expectEqual(null, listed.next());
try std.testing.expectEqual(null, listed.next());
var none = profile.keys("Missions");
try std.testing.expectEqual(null, none.next());
}
test File {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
var file: File = .{ .arena = arena.allocator(), .profile = .empty };
try std.testing.expect(!file.changed);
try file.writeInt("Device", "gamma", -5);
try std.testing.expect(file.changed);
try std.testing.expectEqualStrings("-5", file.profile.value("Device", "gamma").?);
}
test "File.save" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
var file: File = .{ .arena = arena.allocator(), .profile = .empty };
// Nothing changed, so nothing is written.
file.save(io, tmp.dir);
try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, settings_name, .{}));
// A change is written over the file, found in any case.
try tmp.dir.writeFile(io, .{ .sub_path = "STARLANCER.INI", .data = "" });
try file.write("Sound", "Musicvolume", "80");
file.save(io, tmp.dir);
try std.testing.expect(!file.changed);
const read: Profile = .read(io, arena.allocator(), tmp.dir);
try std.testing.expectEqualStrings("80", read.value("Sound", "Musicvolume").?);
}
test atol {
try std.testing.expectEqual(57, atol("57"));
try std.testing.expectEqual(-1, atol(" -1"));
try std.testing.expectEqual(12, atol("12abc"));
try std.testing.expectEqual(0, atol("abc"));
try std.testing.expectEqual(0, atol(""));
}