Pseudo RNGs

I’m trying a very simple “roll a die” example. In C stdlib, this would entail usingsrand()and rand(). In modern C++, there are more sophisticated features, such as std::random_device which provides an entropy()method, std::default_random_engineas well as various other engines, and std::uniform_int_distributionas well as different variants.

My search for Zig examples turned up this very simple one: Zig Cookbook, but it doesn’t cover seeding. I found std.random.int in the std library docs but it doesn’t seem to explain the “underlying magic”. I also found this more extensive example but it’s against an earlier release (I’m using 0.15.2). After fixing it, it looks like this:

const time: u128 = @bitCast(std.time.nanoTimestamp());
const seed: u64 = @truncate(time);
var prng = std.Random.Xoshiro256.init(seed);
const random_number = prng.next();
const die: u8 = (random_number % 6) + 1;

BTW, I had to guess on how to do the above because if you click on DefaultPrngit takes you to Xoshiro256and there are no descriptions in there.

Zig doesn’t like that last line, giving the error error: expected type ‘u8’, found ‘u64’with the note unsigned 8-bit int cannot represent all possible unsigned 64-bit values. What bothers me is that, yes, random_numberis u64, but by the mathematical definition of modulus division, any number modulo 6 can only give an integer between 0 and 5, which definitely fits in a u8, so why should I have to add an additional cast? Am I missing something?

Side note: My original title was “PRNGs” but that was not acceptable because it’s less than 10 characters. I’m left wondering if that is a local limitation or a software “feature” and what purpose does it serve?

1 Like

This recent thread covers some non-cryptographic rng stuff, including using the (juicy) init.io.random for the “easiest” way forward the likes of die-rolling. You might find some help there.

Yes, it looks like next() returns a u64; even though you %6 that value, you could add a million to it, instead of just 1. Obviously you don’t, and you might think the compiler should see that the result can fit into 8 bits, but, alas, you’ll have to @truncate that result. Since you’re sure that won’t modify your value, you can do it safely. This expresses your intent clearly, rather than leaving it as “implied”, anyway.

I didn’t mention it before, but the error is given even if nothing is added, i.e.,

const die: u8 = random_number % 6;

still gives the error with the same error and note. It’s not a big deal, but it seems the compiler would be better if, when processing the % operator it would take into account the size of the divisor rather than invoking “peer type resolution”. In any case, I used an @intCastrather than a @truncatesince the former looks more appropritate.

This works:

const std = @import("std");

pub fn main() !void {
    var prng = std.Random.DefaultPrng.init(@intCast(std.time.nanoTimestamp()));
    const random = prng.random();

    const die = random.intRangeAtMost(u8, 1, 6);
    std.debug.print("{}\n", .{die});
}

The prng.random() call is important here, since it gets you a bunch of nice functions. See here

2 Likes

Note that std.time.nanoTimestamp will be removed in 0.16.

On master, I’d probably write it like this:

pub fn main(init: std.process.Init) !void {
    var prng: std.Random.DefaultPrng = prng: {
        const now: std.Io.Timestamp = .now(init.io, .real);
        // FIXME: this @intCast will only be safe until year 1.255 * 10^12 
        break :prng .init(@intCast(now.toNanoseconds()));
    };
    const random = prng.random();

    const die = random.intRangeAtMost(u8, 1, 6);
    std.debug.print("{}\n", .{die});
}

Or alternatively, if I need my dice rolls to be cryptographically secure:

pub fn main(init: std.process.Init) !void {
    var csprng: std.Random.DefaultCsprng = prng: {
        var seed: [std.Random.DefaultCsprng.secret_seed_length]u8 = undefined;
        init.io.random(&seed); // alternatively: init.io.randomSecure depending on requirements
        break :prng .init(seed);
    };
    const random = csprng.random();

    const die = random.intRangeAtMost(u8, 1, 6);
    std.debug.print("{}\n", .{die});
}

EDIT: Ignore my second suggestion above. You can just use Io’s CSPRNG directly, rather than instantiating another:

pub fn main(init: std.process.Init) !void {
    var csprng: std.Random.IoSource = .{ .io =  init.io };
    const random = csprng.interface();

    const die = random.intRangeAtMost(u8, 1, 6);
    std.debug.print("{}\n", .{die});
}
1 Like

Maybe I’m still unfamiliar with Zig syntax or its documentation, but I don’t see a random() function or method in the page you mention.

The Random type is the “interface”/vtable. @pzittlau’s post instantiates a concrete implementation of that “interface” of type DefaultPrng as a variable called prng. The .random method is on DefaultPrng. I put “interface” in quotes, because Zig doesn’t really have that concept at a language level. Instead, for dynamic dispatch, a struct full of function pointers (a manually created vtable) is often used (usually with convenience wrapper methods).

The .random() call returns the vtable (of type std.Random) associated with the concrete instantiation of DefaultPrng (the variable prng).

2 Likes

I understand what you describe. However, it seems it would be good if this “arcane” information were included somewhere in the language documentation.

1 Like