Browse Source

Initial cut of timezones

Colin Davidson 10 months ago
parent
commit
a6502c3e8c

+ 14 - 0
core/sys/windows/icu.odin

@@ -0,0 +1,14 @@
+#+build windows
+package sys_windows
+
+foreign import "system:icu.lib"
+
+UError :: enum i32 {
+	U_ZERO_ERROR = 0,
+}
+
+@(default_calling_convention="system")
+foreign icu {
+	ucal_getWindowsTimeZoneID :: proc(id: wstring, len: i32, winid: wstring, winidCapacity: i32, status: ^UError) -> i32 ---
+	ucal_getDefaultTimeZone :: proc(result: wstring, cap: i32, status: ^UError) -> i32 ---
+}

+ 44 - 1
core/time/datetime/constants.odin

@@ -77,12 +77,55 @@ Time :: struct {
 	nano:   i32,
 }
 
+TZ_Record :: struct {
+	time:       i64,
+	utc_offset: i64,
+	shortname:  string,
+	dst:        bool,
+}
+
+TZ_Date_Kind :: enum {
+	NoLeap,
+	Leap,
+	MonthWeekDay,
+}
+
+TZ_Transition_Date :: struct {
+	type: TZ_Date_Kind,
+
+	month:  u8,
+	week:   u8,
+	day:    u16,
+
+	time:   i64,
+}
+
+TZ_RRule :: struct {
+	has_dst:    bool,
+
+	std_name:   string,
+	std_offset: i64,
+	std_date:   TZ_Transition_Date,
+
+	dst_name:   string,
+	dst_offset: i64,
+	dst_date:   TZ_Transition_Date,
+}
+
+TZ_Region :: struct {
+	name:       string,
+	records:    []TZ_Record,
+	shortnames: []string,
+	rrule:      TZ_RRule,
+}
+
 /*
 A type representing datetime.
 */
 DateTime :: struct {
 	using date: Date,
 	using time: Time,
+	tz:   ^TZ_Region,
 }
 
 /*
@@ -130,4 +173,4 @@ Weekday :: enum i8 {
 }
 
 @(private)
-MONTH_DAYS :: [?]i8{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
+MONTH_DAYS :: [?]i8{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}

+ 3 - 3
core/time/datetime/datetime.odin

@@ -76,7 +76,7 @@ datetime, an error is returned.
 components_to_datetime :: proc "contextless" (#any_int year, #any_int month, #any_int day, #any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (datetime: DateTime, err: Error) {
 	date := components_to_date(year, month, day)            or_return
 	time := components_to_time(hour, minute, second, nanos) or_return
-	return {date, time}, .None
+	return {date, time, nil}, .None
 }
 
 /*
@@ -88,7 +88,7 @@ object will always have the time equal to `00:00:00.000`.
 */
 ordinal_to_datetime :: proc "contextless" (ordinal: Ordinal) -> (datetime: DateTime, err: Error) {
 	d := ordinal_to_date(ordinal) or_return
-	return {Date(d), {}}, .None
+	return {Date(d), {}, nil}, .None
 }
 
 /*
@@ -433,4 +433,4 @@ unsafe_ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date)
 	day   := i8(ordinal - unsafe_date_to_ordinal(Date{year, month, 1}) + 1)
 
 	return {year, month, day}
-}
+}

+ 2 - 2
core/time/time.odin

@@ -930,7 +930,7 @@ If the datetime represents a time outside of a valid range, `false` is returned
 as the second return value. See `Time` for the representable range.
 */
 compound_to_time :: proc "contextless" (datetime: dt.DateTime) -> (t: Time, ok: bool) {
-	unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}}
+	unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil}
 	delta, err := dt.sub(datetime, unix_epoch)
 	if err != .None {
 		return
@@ -958,7 +958,7 @@ datetime_to_time :: proc{components_to_time, compound_to_time}
 Convert time into datetime.
 */
 time_to_datetime :: proc "contextless" (t: Time) -> (dt.DateTime, bool) {
-	unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}}
+	unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil}
 
 	datetime, err := dt.add(unix_epoch, dt.Delta{ nanos = t._nsec })
 	if err != .None {

+ 85 - 0
core/time/timezone/tz_unix.odin

@@ -0,0 +1,85 @@
+#+build darwin, linux, freebsd, openbsd, netbsd
+#+private
+package timezone
+
+import "core:os"
+import "core:strings"
+import "core:path/filepath"
+import "core:time/datetime"
+
+local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) {
+	local_str, ok := os.lookup_env("TZ", allocator)
+	if !ok {
+		orig_localtime_path := "/etc/localtime"
+		path, err := os.absolute_path_from_relative(orig_localtime_path)
+		if err != nil {
+			// If we can't find /etc/localtime, fallback to UTC
+			if err == .ENOENT {
+				str, err2 := strings.clone("UTC", allocator)
+				if err2 != nil { return }
+				return str, true
+			}
+
+			return
+		}
+		defer delete(path)
+
+		// FreeBSD makes me sad.
+		if path == orig_localtime_path {
+			data := os.read_entire_file("/var/db/zoneinfo") or_return
+			return strings.trim_right_space(string(data)), true
+		}
+
+		// Looking for tz path (ex fmt: "UTC", "Etc/UTC" or "America/Los_Angeles")
+		path_dir, path_file := filepath.split(path)
+		if path_dir == "" {
+			return
+		}
+		upper_path_dir, upper_path_chunk := filepath.split(path_dir[:len(path_dir)-1])
+		if upper_path_dir == "" {
+			return
+		}
+
+		if strings.contains(upper_path_chunk, "zoneinfo") {
+			region_str, err := strings.clone(path_file, allocator)
+			if err != nil { return }
+			return region_str, true
+		} else {
+			region_str, err := filepath.join({upper_path_chunk, path_file}, allocator = allocator)
+			if err != nil { return }
+			return region_str, true
+		}
+	}
+
+	if local_str == "" {
+		str, err := strings.clone("UTC", allocator)
+		if err != nil { return }
+		return str, true
+	}
+
+	return local_str, true
+}
+
+_region_load :: proc(_reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) {
+	reg_str := _reg_str
+	if reg_str == "UTC" {
+		return nil, true
+	}
+
+	if reg_str == "local" {
+		local_name := local_tz_name(allocator) or_return
+		if local_name == "UTC" {
+			delete(local_name, allocator)
+			return nil, true
+		}
+
+		reg_str = local_name
+	}
+	defer if _reg_str == "local" { delete(reg_str, allocator) }
+
+	db_path := "/usr/share/zoneinfo"
+	region_path := filepath.join({db_path, reg_str}, allocator)
+	defer delete(region_path, allocator)
+
+	return load_tzif_file(region_path, reg_str, allocator)
+}

+ 295 - 0
core/time/timezone/tz_windows.odin

@@ -0,0 +1,295 @@
+#+build windows
+#+private
+package timezone
+
+import "core:strings"
+import "core:sys/windows"
+import "core:time/datetime"
+
+TZ_Abbrev :: struct {
+	std: string,
+	dst: string,
+}
+
+tz_abbrevs := map[string]TZ_Abbrev {
+	"Egypt Standard Time"             = {"EET", "EEST"},    // Africa/Cairo
+	"Morocco Standard Time"           = {"+00", "+01"},     // Africa/Casablanca
+	"South Africa Standard Time"      = {"SAST", "SAST"},   // Africa/Johannesburg
+	"South Sudan Standard Time"       = {"CAT", "CAT"},     // Africa/Juba
+	"Sudan Standard Time"             = {"CAT", "CAT"},     // Africa/Khartoum
+	"W. Central Africa Standard Time" = {"WAT", "WAT"},     // Africa/Lagos
+	"E. Africa Standard Time"         = {"EAT", "EAT"},     // Africa/Nairobi
+	"Sao Tome Standard Time"          = {"GMT", "GMT"},     // Africa/Sao_Tome
+	"Libya Standard Time"             = {"EET", "EET"},     // Africa/Tripoli
+	"Namibia Standard Time"           = {"CAT", "CAT"},     // Africa/Windhoek
+	"Aleutian Standard Time"          = {"HST", "HDT"},     // America/Adak
+	"Alaskan Standard Time"           = {"AKST", "AKDT"},   // America/Anchorage
+	"Tocantins Standard Time"         = {"-03", "-03"},     // America/Araguaina
+	"Paraguay Standard Time"          = {"-04", "-03"},     // America/Asuncion
+	"Bahia Standard Time"             = {"-03", "-03"},     // America/Bahia
+	"SA Pacific Standard Time"        = {"-05", "-05"},     // America/Bogota
+	"Argentina Standard Time"         = {"-03", "-03"},     // America/Buenos_Aires
+	"Eastern Standard Time (Mexico)"  = {"EST", "EST"},     // America/Cancun
+	"Venezuela Standard Time"         = {"-04", "-04"},     // America/Caracas
+	"SA Eastern Standard Time"        = {"-03", "-03"},     // America/Cayenne
+	"Central Standard Time"           = {"CST", "CDT"},     // America/Chicago
+	"Central Brazilian Standard Time" = {"-04", "-04"},     // America/Cuiaba
+	"Mountain Standard Time"          = {"MST", "MDT"},     // America/Denver
+	"Greenland Standard Time"         = {"-03", "-02"},     // America/Godthab
+	"Turks And Caicos Standard Time"  = {"EST", "EDT"},     // America/Grand_Turk
+	"Central America Standard Time"   = {"CST", "CST"},     // America/Guatemala
+	"Atlantic Standard Time"          = {"AST", "ADT"},     // America/Halifax
+	"Cuba Standard Time"              = {"CST", "CDT"},     // America/Havana
+	"US Eastern Standard Time"        = {"EST", "EDT"},     // America/Indianapolis
+	"SA Western Standard Time"        = {"-04", "-04"},     // America/La_Paz
+	"Pacific Standard Time"           = {"PST", "PDT"},     // America/Los_Angeles
+	"Mountain Standard Time (Mexico)" = {"MST", "MST"},     // America/Mazatlan
+	"Central Standard Time (Mexico)"  = {"CST", "CST"},     // America/Mexico_City
+	"Saint Pierre Standard Time"      = {"-03", "-02"},     // America/Miquelon
+	"Montevideo Standard Time"        = {"-03", "-03"},     // America/Montevideo
+	"Eastern Standard Time"           = {"EST", "EDT"},     // America/New_York
+	"US Mountain Standard Time"       = {"MST", "MST"},     // America/Phoenix
+	"Haiti Standard Time"             = {"EST", "EDT"},     // America/Port-au-Prince
+	"Magallanes Standard Time"        = {"-03", "-03"},     // America/Punta_Arenas
+	"Canada Central Standard Time"    = {"CST", "CST"},     // America/Regina
+	"Pacific SA Standard Time"        = {"-04", "-03"},     // America/Santiago
+	"E. South America Standard Time"  = {"-03", "-03"},     // America/Sao_Paulo
+	"Newfoundland Standard Time"      = {"NST", "NDT"},     // America/St_Johns
+	"Pacific Standard Time (Mexico)"  = {"PST", "PDT"},     // America/Tijuana
+	"Yukon Standard Time"             = {"MST", "MST"},     // America/Whitehorse
+	"Central Asia Standard Time"      = {"+06", "+06"},     // Asia/Almaty
+	"Jordan Standard Time"            = {"+03", "+03"},     // Asia/Amman
+	"Arabic Standard Time"            = {"+03", "+03"},     // Asia/Baghdad
+	"Azerbaijan Standard Time"        = {"+04", "+04"},     // Asia/Baku
+	"SE Asia Standard Time"           = {"+07", "+07"},     // Asia/Bangkok
+	"Altai Standard Time"             = {"+07", "+07"},     // Asia/Barnaul
+	"Middle East Standard Time"       = {"EET", "EEST"},    // Asia/Beirut
+	"India Standard Time"             = {"IST", "IST"},     // Asia/Calcutta
+	"Transbaikal Standard Time"       = {"+09", "+09"},     // Asia/Chita
+	"Sri Lanka Standard Time"         = {"+0530", "+0530"}, // Asia/Colombo
+	"Syria Standard Time"             = {"+03", "+03"},     // Asia/Damascus
+	"Bangladesh Standard Time"        = {"+06", "+06"},     // Asia/Dhaka
+	"Arabian Standard Time"           = {"+04", "+04"},     // Asia/Dubai
+	"West Bank Standard Time"         = {"EET", "EEST"},    // Asia/Hebron
+	"W. Mongolia Standard Time"       = {"+07", "+07"},     // Asia/Hovd
+	"North Asia East Standard Time"   = {"+08", "+08"},     // Asia/Irkutsk
+	"Israel Standard Time"            = {"IST", "IDT"},     // Asia/Jerusalem
+	"Afghanistan Standard Time"       = {"+0430", "+0430"}, // Asia/Kabul
+	"Russia Time Zone 11"             = {"+12", "+12"},     // Asia/Kamchatka
+	"Pakistan Standard Time"          = {"PKT", "PKT"},     // Asia/Karachi
+	"Nepal Standard Time"             = {"+0545", "+0545"}, // Asia/Katmandu
+	"North Asia Standard Time"        = {"+07", "+07"},     // Asia/Krasnoyarsk
+	"Magadan Standard Time"           = {"+11", "+11"},     // Asia/Magadan
+	"N. Central Asia Standard Time"   = {"+07", "+07"},     // Asia/Novosibirsk
+	"Omsk Standard Time"              = {"+06", "+06"},     // Asia/Omsk
+	"North Korea Standard Time"       = {"KST", "KST"},     // Asia/Pyongyang
+	"Qyzylorda Standard Time"         = {"+05", "+05"},     // Asia/Qyzylorda
+	"Myanmar Standard Time"           = {"+0630", "+0630"}, // Asia/Rangoon
+	"Arab Standard Time"              = {"+03", "+03"},     // Asia/Riyadh
+	"Sakhalin Standard Time"          = {"+11", "+11"},     // Asia/Sakhalin
+	"Korea Standard Time"             = {"KST", "KST"},     // Asia/Seoul
+	"China Standard Time"             = {"CST", "CST"},     // Asia/Shanghai
+	"Singapore Standard Time"         = {"+08", "+08"},     // Asia/Singapore
+	"Russia Time Zone 10"             = {"+11", "+11"},     // Asia/Srednekolymsk
+	"Taipei Standard Time"            = {"CST", "CST"},     // Asia/Taipei
+	"West Asia Standard Time"         = {"+05", "+05"},     // Asia/Tashkent
+	"Georgian Standard Time"          = {"+04", "+04"},     // Asia/Tbilisi
+	"Iran Standard Time"              = {"+0330", "+0330"}, // Asia/Tehran
+	"Tokyo Standard Time"             = {"JST", "JST"},     // Asia/Tokyo
+	"Tomsk Standard Time"             = {"+07", "+07"},     // Asia/Tomsk
+	"Ulaanbaatar Standard Time"       = {"+08", "+08"},     // Asia/Ulaanbaatar
+	"Vladivostok Standard Time"       = {"+10", "+10"},     // Asia/Vladivostok
+	"Yakutsk Standard Time"           = {"+09", "+09"},     // Asia/Yakutsk
+	"Ekaterinburg Standard Time"      = {"+05", "+05"},     // Asia/Yekaterinburg
+	"Caucasus Standard Time"          = {"+04", "+04"},     // Asia/Yerevan
+	"Azores Standard Time"            = {"-01", "+00"},     // Atlantic/Azores
+	"Cape Verde Standard Time"        = {"-01", "-01"},     // Atlantic/Cape_Verde
+	"Greenwich Standard Time"         = {"GMT", "GMT"},     // Atlantic/Reykjavik
+	"Cen. Australia Standard Time"    = {"ACST", "ACDT"},   // Australia/Adelaide
+	"E. Australia Standard Time"      = {"AEST", "AEST"},   // Australia/Brisbane
+	"AUS Central Standard Time"       = {"ACST", "ACST"},   // Australia/Darwin
+	"Aus Central W. Standard Time"    = {"+0845", "+0845"}, // Australia/Eucla
+	"Tasmania Standard Time"          = {"AEST", "AEDT"},   // Australia/Hobart
+	"Lord Howe Standard Time"         = {"+1030", "+11"},   // Australia/Lord_Howe
+	"W. Australia Standard Time"      = {"AWST", "AWST"},   // Australia/Perth
+	"AUS Eastern Standard Time"       = {"AEST", "AEDT"},   // Australia/Sydney
+	"UTC-11"                          = {"-11", "-11"},     // Etc/GMT+11
+	"Dateline Standard Time"          = {"-12", "-12"},     // Etc/GMT+12
+	"UTC-02"                          = {"-02", "-02"},     // Etc/GMT+2
+	"UTC-08"                          = {"-08", "-08"},     // Etc/GMT+8
+	"UTC-09"                          = {"-09", "-09"},     // Etc/GMT+9
+	"UTC+12"                          = {"+12", "+12"},     // Etc/GMT-12
+	"UTC+13"                          = {"+13", "+13"},     // Etc/GMT-13
+	"UTC"                             = {"UTC", "UTC"},     // Etc/UTC
+	"Astrakhan Standard Time"         = {"+04", "+04"},     // Europe/Astrakhan
+	"W. Europe Standard Time"         = {"CET", "CEST"},    // Europe/Berlin
+	"GTB Standard Time"               = {"EET", "EEST"},    // Europe/Bucharest
+	"Central Europe Standard Time"    = {"CET", "CEST"},    // Europe/Budapest
+	"E. Europe Standard Time"         = {"EET", "EEST"},    // Europe/Chisinau
+	"Turkey Standard Time"            = {"+03", "+03"},     // Europe/Istanbul
+	"Kaliningrad Standard Time"       = {"EET", "EET"},     // Europe/Kaliningrad
+	"FLE Standard Time"               = {"EET", "EEST"},    // Europe/Kiev
+	"GMT Standard Time"               = {"GMT", "BST"},     // Europe/London
+	"Belarus Standard Time"           = {"+03", "+03"},     // Europe/Minsk
+	"Russian Standard Time"           = {"MSK", "MSK"},     // Europe/Moscow
+	"Romance Standard Time"           = {"CET", "CEST"},    // Europe/Paris
+	"Russia Time Zone 3"              = {"+04", "+04"},     // Europe/Samara
+	"Saratov Standard Time"           = {"+04", "+04"},     // Europe/Saratov
+	"Volgograd Standard Time"         = {"MSK", "MSK"},     // Europe/Volgograd
+	"Central European Standard Time"  = {"CET", "CEST"},    // Europe/Warsaw
+	"Mauritius Standard Time"         = {"+04", "+04"},     // Indian/Mauritius
+	"Samoa Standard Time"             = {"+13", "+13"},     // Pacific/Apia
+	"New Zealand Standard Time"       = {"NZST", "NZDT"},   // Pacific/Auckland
+	"Bougainville Standard Time"      = {"+11", "+11"},     // Pacific/Bougainville
+	"Chatham Islands Standard Time"   = {"+1245", "+1345"}, // Pacific/Chatham
+	"Easter Island Standard Time"     = {"-06", "-05"},     // Pacific/Easter
+	"Fiji Standard Time"              = {"+12", "+12"},     // Pacific/Fiji
+	"Central Pacific Standard Time"   = {"+11", "+11"},     // Pacific/Guadalcanal
+	"Hawaiian Standard Time"          = {"HST", "HST"},     // Pacific/Honolulu
+	"Line Islands Standard Time"      = {"+14", "+14"},     // Pacific/Kiritimati
+	"Marquesas Standard Time"         = {"-0930", "-0930"}, // Pacific/Marquesas
+	"Norfolk Standard Time"           = {"+11", "+12"},     // Pacific/Norfolk
+	"West Pacific Standard Time"      = {"+10", "+10"},     // Pacific/Port_Moresby
+	"Tonga Standard Time"             = {"+13", "+13"},     // Pacific/Tongatapu
+}
+
+iana_to_windows_tz :: proc(iana_name: string, allocator := context.allocator) -> (name: string, success: bool) {
+	wintz_name_buffer: [128]u16
+	status: windows.UError 
+
+	iana_name_wstr := windows.utf8_to_wstring(iana_name, allocator)
+	defer free(iana_name_wstr, allocator)
+
+	wintz_name_len := windows.ucal_getWindowsTimeZoneID(iana_name_wstr, -1, raw_data(wintz_name_buffer[:]), len(wintz_name_buffer), &status)
+	if status != .U_ZERO_ERROR {
+		return
+	}
+
+	wintz_name, err := windows.utf16_to_utf8(wintz_name_buffer[:wintz_name_len], allocator)
+	if err != nil {
+		return
+	}
+
+	return wintz_name, true
+}
+
+local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) {
+	iana_name_buffer: [128]u16
+	status: windows.UError
+
+	zone_str_len := windows.ucal_getDefaultTimeZone(raw_data(iana_name_buffer[:]), len(iana_name_buffer), &status)
+	if status != .U_ZERO_ERROR {
+		return
+	}
+
+	iana_name, err := windows.utf16_to_utf8(iana_name_buffer[:zone_str_len], allocator)
+	if err != nil {
+		return
+	}
+
+	return iana_name, true
+}
+
+REG_TZI_FORMAT :: struct #packed {
+	bias:     windows.LONG,
+	std_bias: windows.LONG,
+	dst_bias: windows.LONG,
+	std_date: windows.SYSTEMTIME,
+	dst_date: windows.SYSTEMTIME,
+}
+
+generate_rrule_from_tzi :: proc(tzi: ^REG_TZI_FORMAT, abbrevs: TZ_Abbrev, allocator := context.allocator) -> (rrule: datetime.TZ_RRule, ok: bool) {
+	std_name, err := strings.clone(abbrevs.std, allocator)
+	if err != nil { return }
+	defer if err != nil { delete(std_name, allocator) }
+
+	dst_name: string
+	dst_name, err = strings.clone(abbrevs.dst, allocator)
+	if err != nil { return }
+	defer if err != nil { delete(dst_name, allocator) }
+
+	return datetime.TZ_RRule{
+		has_dst = true,
+
+		std_name = std_name,
+		std_offset = -(i64(tzi.bias) + i64(tzi.std_bias)) * 60,
+		dst_date = datetime.TZ_Transition_Date{
+			type = .MonthWeekDay,
+			month = u8(tzi.std_date.month),
+			week = u8(tzi.std_date.day),
+			day = tzi.std_date.day_of_week,
+			time = (i64(tzi.std_date.hour) * 60 * 60) + (i64(tzi.std_date.minute) * 60) + i64(tzi.std_date.second),
+		},
+
+		dst_name = dst_name,
+		dst_offset = -(i64(tzi.bias) + i64(tzi.dst_bias)) * 60,
+		std_date = datetime.TZ_Transition_Date{
+			type = .MonthWeekDay,
+			month = u8(tzi.dst_date.month),
+			week = u8(tzi.dst_date.day),
+			day = tzi.dst_date.day_of_week,
+			time = (i64(tzi.dst_date.hour) * 60 * 60) + (i64(tzi.dst_date.minute) * 60) + i64(tzi.dst_date.second),
+		},
+	}, true
+}
+
+_region_load :: proc(reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) {
+	wintz_name: string
+	iana_name: string
+
+	if reg_str == "local" {
+		ok := false
+
+		iana_name = local_tz_name(allocator) or_return
+		wintz_name, ok = iana_to_windows_tz(iana_name, allocator)
+		if !ok {
+			delete(iana_name, allocator)
+			return
+		}
+	} else {
+		wintz_name = iana_to_windows_tz(reg_str, allocator) or_return
+		iana_name = strings.clone(reg_str, allocator)
+	}
+	defer delete(wintz_name, allocator)
+	defer delete(iana_name, allocator)
+
+	abbrevs := tz_abbrevs[wintz_name] or_return
+	if abbrevs.std == "UTC" && abbrevs.dst == abbrevs.std {
+		return nil, true
+	}
+
+	key_base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones`
+	tz_key := strings.join({key_base, wintz_name}, "\\", allocator = allocator)
+	defer delete(tz_key, allocator)
+
+	tz_key_wstr := windows.utf8_to_wstring(tz_key, allocator)
+	defer free(tz_key_wstr, allocator)
+
+	key: windows.HKEY
+	res := windows.RegOpenKeyExW(windows.HKEY_LOCAL_MACHINE, tz_key_wstr, 0, windows.KEY_READ, &key)
+	if res != 0 { return }
+	defer windows.RegCloseKey(key)
+
+	tzi: REG_TZI_FORMAT
+	size := u32(size_of(REG_TZI_FORMAT))
+
+	res = windows.RegGetValueW(key, nil, windows.L("TZI"), windows.RRF_RT_ANY, nil, &tzi, &size)
+	if res != 0 {
+		return
+	}
+
+	rrule := generate_rrule_from_tzi(&tzi, abbrevs, allocator) or_return
+
+	region_name, err := strings.clone(iana_name, allocator)
+	if err != nil { return }
+	defer if err != nil { delete(region_name, allocator) }
+
+	region: ^datetime.TZ_Region
+	region, err = new_clone(datetime.TZ_Region{
+		name       = region_name,
+		rrule      = rrule,
+	}, allocator)
+	if err != nil { return }
+
+	return region, true
+}

+ 320 - 0
core/time/timezone/tzdate.odin

@@ -0,0 +1,320 @@
+package timezone
+
+import "core:fmt"
+import "core:slice"
+import "core:time"
+import "core:time/datetime"
+
+region_load :: proc(reg: string, allocator := context.allocator) ->  (out_reg: ^datetime.TZ_Region, ok: bool) {
+	return _region_load(reg, allocator)
+}
+
+region_load_from_file :: proc(file_path, reg: string, allocator := context.allocator) ->  (out_reg: ^datetime.TZ_Region, ok: bool) {
+	return load_tzif_file(file_path, reg, allocator)
+}
+
+region_load_from_buffer :: proc(buffer: []u8, reg: string, allocator := context.allocator) ->  (out_reg: ^datetime.TZ_Region, ok: bool) {
+	return parse_tzif(buffer, reg, allocator)
+}
+
+rrule_destroy :: proc(rrule: datetime.TZ_RRule, allocator := context.allocator) {
+	delete(rrule.std_name, allocator)
+	delete(rrule.dst_name, allocator)
+}
+
+region_destroy :: proc(region: ^datetime.TZ_Region, allocator := context.allocator) {
+	if region == nil {
+		return
+	}
+
+	for name in region.shortnames {
+		delete(name, allocator)
+	}
+	delete(region.shortnames, allocator)
+	delete(region.records, allocator)
+	delete(region.name, allocator)
+	rrule_destroy(region.rrule, allocator)
+	free(region, allocator)
+}
+
+
+region_get_nearest :: proc(region: ^datetime.TZ_Region, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) {
+	if len(region.records) == 0 {
+		return process_rrule(region.rrule, tm)
+	}
+
+	n := len(region.records)
+	left, right := 0, n
+
+	tm_sec := time.to_unix_seconds(tm)
+	last_time := region.records[len(region.records)-1].time
+	if tm_sec > last_time {
+		return process_rrule(region.rrule, tm)
+	}
+
+	for left < right {
+		mid := int(uint(left+right) >> 1)
+		if region.records[mid].time < tm_sec {
+			left = mid + 1
+		} else {
+			right = mid
+		}
+	}
+
+	idx := max(0, left-1)
+	return region.records[idx], true
+}
+
+@private
+month_to_seconds :: proc(month: int, is_leap: bool) -> i64 {
+	month_seconds := []i64{
+		0,             31 * 86_400,  59 * 86_400,  90 * 86_400,
+		120 * 86_400, 151 * 86_400, 181 * 86_400, 212 * 86_400,
+		243 * 86_400, 273 * 86_400, 304 * 86_400, 334 * 86_400,
+	}
+
+	t := month_seconds[month]
+	if is_leap && month >= 2 {
+		t += 86_400
+	}
+	return t
+}
+
+@private
+trans_date_to_seconds :: proc(year: i64, td: datetime.TZ_Transition_Date) -> (secs: i64, ok: bool) {
+	is_leap := datetime.is_leap_year(year)
+	ONE_DAY :: 86_400
+
+	#partial switch td.type {
+	case .MonthWeekDay:
+		year_start := datetime.DateTime{{year, 1, 1}, {0, 0, 0, 0}, nil}
+		year_start_time := time.datetime_to_time(year_start) or_return
+
+		t := i64(time.to_unix_seconds(year_start_time))
+		t += month_to_seconds(int(td.month) - 1, is_leap)
+
+		weekday := ((t + (4 * ONE_DAY)) %% (7 * ONE_DAY)) / ONE_DAY
+		days := i64(td.day) - weekday
+		if days < 0 { days += 7 }
+
+		month_daycount, err := datetime.last_day_of_month(year, td.month)
+		if err != nil { return }
+
+		week := td.week
+		if week == 5 && days + 28 >= i64(month_daycount) {
+			week = 4
+		}
+
+		t += 86_400 * (days + (7 * i64(week - 1)))
+		t += td.time
+
+		return t, true
+	case:
+		return
+	}
+
+	return
+}
+
+process_rrule :: proc(rrule: datetime.TZ_RRule, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) {
+	if !rrule.has_dst {
+		return datetime.TZ_Record{
+			time       = time.to_unix_seconds(tm),
+			utc_offset = rrule.std_offset,
+			shortname  = rrule.std_name,
+			dst        = false,
+		}, true
+	}
+
+	y, _, _ := time.date(tm)
+	std_secs := trans_date_to_seconds(i64(y), rrule.std_date) or_return
+	dst_secs := trans_date_to_seconds(i64(y), rrule.dst_date) or_return
+
+	records := []datetime.TZ_Record{
+		{
+			time = std_secs,
+			utc_offset = rrule.std_offset,
+			shortname  = rrule.std_name,
+			dst        = false,
+		},
+		{
+			time = dst_secs,
+			utc_offset = rrule.dst_offset,
+			shortname  = rrule.dst_name,
+			dst        = true,
+		},
+	}
+	record_sort_proc :: proc(i, j: datetime.TZ_Record) -> bool {
+		return i.time > j.time
+	}
+	slice.sort_by(records, record_sort_proc)
+
+	tm_sec := time.to_unix_seconds(tm)
+	for record in records {
+		if tm_sec < record.time {
+			return record, true
+		}
+	}
+
+	return records[len(records)-1], true
+}
+
+datetime_to_utc :: proc(dt: datetime.DateTime) -> (out: datetime.DateTime, success: bool) #optional_ok {
+	if dt.tz == nil {
+		return dt, true
+	}
+
+	tm := time.datetime_to_time(dt) or_return
+	record := region_get_nearest(dt.tz, tm) or_return
+
+	secs := time.time_to_unix(tm)
+	adj_time := time.unix(secs - record.utc_offset, 0)
+	adj_dt := time.time_to_datetime(adj_time) or_return
+	return adj_dt, true
+}
+
+/*
+Converts a datetime on one timezone to another timezone
+
+Inputs:
+- dt: The input datetime
+- tz: The timezone to convert to
+
+NOTE: tz will be referenced in the result datetime, so it must stay alive/allocated as long as it is used
+Returns:
+- out: The converted datetime
+- success: `false` if the datetime was invalid
+*/
+datetime_to_tz :: proc(dt: datetime.DateTime, tz: ^datetime.TZ_Region) -> (out: datetime.DateTime, success: bool) #optional_ok {
+	dt := dt
+	if dt.tz == tz {
+		return dt, true
+	}
+	if dt.tz != nil {
+		dt = datetime_to_utc(dt)
+	}
+	if tz == nil {
+		return dt, true
+	}
+
+	tm := time.datetime_to_time(dt) or_return
+	record := region_get_nearest(tz, tm) or_return
+
+	secs := time.time_to_unix(tm)
+	adj_time := time.unix(secs + record.utc_offset, 0)
+	adj_dt := time.time_to_datetime(adj_time) or_return
+	adj_dt.tz = tz
+
+	return adj_dt, true
+}
+
+/*
+Gets the timezone abbreviation/shortname for a given date.
+(ex: "PDT")
+
+Inputs:
+- dt: The datetime containing the date, time, and timezone pointer for the lookup
+
+NOTE: The lifetime of name matches the timezone it was pulled from.
+Returns:
+- name: The timezone abbreviation
+- success: returns `false` if the passed datetime is invalid
+*/
+shortname :: proc(dt: datetime.DateTime) -> (name: string, success: bool) #optional_ok {
+	tm := time.datetime_to_time(dt) or_return
+	if dt.tz == nil { return "UTC", true }
+
+	record := region_get_nearest(dt.tz, tm) or_return
+	return record.shortname, true
+}
+
+/*
+Gets the timezone abbreviation/shortname for a given date.
+(ex: "PDT")
+
+WARNING: This is unsafe because it doesn't check if you datetime is valid, or your region contains a valid record
+
+Inputs:
+- dt: The input datetime
+
+NOTE: The lifetime of name matches the timezone it was pulled from.
+Returns:
+- name: The timezone abbreviation
+*/
+shortname_unsafe :: proc(dt: datetime.DateTime) -> string {
+	if dt.tz == nil { return "UTC" }
+
+	tm, _ := time.datetime_to_time(dt)
+	record, _ := region_get_nearest(dt.tz, tm)
+	return record.shortname
+}
+
+/*
+Checks DST for a given date.
+
+Inputs:
+- dt: The input datetime
+
+Returns:
+- is_dst: returns `true` if dt is in daylight savings time, `false` if not
+- success: returns `false` if the passed datetime is invalid
+*/
+dst :: proc(dt: datetime.DateTime) -> (is_dst: bool, success: bool) #optional_ok {
+	tm := time.datetime_to_time(dt) or_return
+	if dt.tz == nil { return false, true }
+
+	record := region_get_nearest(dt.tz, tm) or_return
+	return record.dst, true
+}
+
+/*
+Checks DST for a given date.
+
+WARNING: This is unsafe because it doesn't check if you datetime is valid, or your region contains a valid record
+
+Inputs:
+- dt: The input datetime
+
+Returns:
+- is_dst: returns `true` if dt is in daylight savings time, `false` if not
+*/
+dst_unsafe :: proc(dt: datetime.DateTime) -> bool {
+	if dt.tz == nil { return false }
+
+	tm, _ := time.datetime_to_time(dt)
+	record, _ := region_get_nearest(dt.tz, tm)
+	return record.dst
+}
+
+datetime_to_str :: proc(dt: datetime.DateTime, allocator := context.temp_allocator) -> string {
+	context.temp_allocator = allocator
+
+	if dt.tz == nil {
+		_, ok := time.datetime_to_time(dt)
+		if !ok {
+			return ""
+		}
+
+		return fmt.tprintf("%02d-%02d-%04d @ %02d:%02d:%02d UTC", dt.month, dt.day, dt.year, dt.hour, dt.minute, dt.second)
+
+	} else {
+		tm, ok := time.datetime_to_time(dt)
+		if !ok {
+			return ""
+		}
+
+		record, ok2 := region_get_nearest(dt.tz, tm)
+		if !ok2 {
+			return ""
+		}
+
+		hour := dt.hour
+		am_pm_str := "AM"
+		if hour > 12 {
+			am_pm_str = "PM"
+			hour -= 12
+		}
+
+		return fmt.tprintf("%02d-%02d-%04d @ %02d:%02d:%02d %s %s", dt.month, dt.day, dt.year, hour, dt.minute, dt.second, am_pm_str, record.shortname)
+	}
+}

+ 652 - 0
core/time/timezone/tzif.odin

@@ -0,0 +1,652 @@
+package timezone
+
+import "base:intrinsics"
+
+import "core:slice"
+import "core:strings"
+import "core:os"
+import "core:strconv"
+import "core:time/datetime"
+
+// Implementing RFC8536 [https://datatracker.ietf.org/doc/html/rfc8536]
+
+TZIF_MAGIC :: u32be(0x545A6966) // 'TZif'
+TZif_Version :: enum u8 {
+	V1 =  0,
+	V2 = '2',
+	V3 = '3',
+	V4 = '4',
+}
+BIG_BANG_ISH :: -0x800000000000000
+
+TZif_Header :: struct #packed {
+	magic:    u32be,
+	version:  TZif_Version,
+	reserved: [15]u8,
+	isutcnt:  u32be,
+	isstdcnt: u32be,
+	leapcnt:  u32be,
+	timecnt:  u32be,
+	typecnt:  u32be,
+	charcnt:  u32be,
+}
+
+Sun_Shift :: enum u8 {
+	Standard = 0,
+	DST      = 1,
+}
+
+Local_Time_Type :: struct #packed {
+	utoff: i32be,
+	dst:   Sun_Shift,
+	idx:   u8,
+}
+
+Leapsecond_Record :: struct #packed {
+	occur: i64be,
+	corr:  i32be,
+}
+
+@private
+tzif_data_block_size :: proc(hdr: ^TZif_Header, version: TZif_Version) -> (block_size: int, ok: bool) {
+	time_size : int
+
+	if version == .V1 {
+		time_size = 4
+	} else if version == .V2 || version == .V3 || version == .V4 {
+		time_size = 8
+	} else {
+		return
+	}
+
+	return (int(hdr.timecnt) * time_size)              +
+		   int(hdr.timecnt)                            +
+		   int(hdr.typecnt * size_of(Local_Time_Type)) +
+		   int(hdr.charcnt)                            +
+		   (int(hdr.leapcnt) * (time_size + 4))        +
+		   int(hdr.isstdcnt)                           +
+		   int(hdr.isutcnt), true
+}
+
+
+load_tzif_file :: proc(filename: string, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) {
+	tzif_data := os.read_entire_file_from_filename(filename, allocator) or_return
+	defer delete(tzif_data, allocator)
+	return parse_tzif(tzif_data, region_name, allocator)
+}
+
+@private
+is_alphabetic :: proc(ch: u8) -> bool {
+	//     ('A' -> 'Z')             || ('a' -> 'z')
+	return (ch > 0x40 && ch < 0x5B) || (ch > 0x60 && ch < 0x7B)
+}
+
+@private
+is_numeric :: proc(ch: u8) -> bool {
+	//     ('0' -> '9')
+	return (ch > 0x2F && ch < 0x3A)
+}
+
+@private
+is_alphanumeric :: proc(ch: u8) -> bool {
+	return is_alphabetic(ch) || is_numeric(ch)
+}
+
+@private
+is_valid_quoted_char :: proc(ch: u8) -> bool {
+	return is_alphabetic(ch) || is_numeric(ch) || ch == '+' || ch == '-'
+}
+
+@private
+parse_posix_tz_shortname :: proc(str: string) -> (out: string, idx: int, ok: bool) {
+	was_quoted := false
+	quoted := false
+	i := 0
+
+	for ; i < len(str); i += 1 {
+		ch := str[i]
+
+		if !quoted && ch == '<' {
+			quoted = true
+			was_quoted = true
+			continue
+		}
+
+		if quoted && ch == '>' {
+			quoted = false
+			break
+		}
+
+		if !is_valid_quoted_char(ch) && ch != ',' {
+			return
+		}
+
+		if !quoted && !is_alphabetic(ch) {
+			break
+		}
+	}
+
+	// If we didn't see the trailing quote
+	if was_quoted && quoted {
+		return
+	}
+
+	out_str: string
+	end_idx := i
+	if was_quoted {
+		end_idx += 1
+		out_str = str[1:i]
+	} else {
+		out_str = str[:i]
+	}
+
+	return out_str, end_idx, true
+}
+
+@private
+parse_posix_tz_offset :: proc(str: string) -> (out_sec: i64, idx: int, ok: bool) {
+	str := str
+
+	sign : i64 = 1
+	start_idx := 0
+	i := 0
+	if str[i] == '+' {
+		i += 1
+		sign = 1
+		start_idx = 1
+	} else if str[i] == '-' {
+		i += 1
+		sign = -1
+		start_idx = 1
+	}
+
+	got_more_time := false
+	for ; i < len(str); i += 1 {
+		if is_numeric(str[i]) {
+			continue
+		}
+
+		if str[i] == ':' {
+			got_more_time = true
+			break
+		}
+		
+		break
+	}
+
+	ret_sec : i64 = 0
+	hours := strconv.parse_int(str[start_idx:i], 10) or_return
+	if hours > 167 || hours < -167 {
+		return
+	}
+	ret_sec += i64(hours) * (60 * 60)
+	if !got_more_time {
+		return ret_sec * sign, i, true
+	}
+
+	i += 1
+	start_idx = i
+
+	got_more_time = false
+	for ; i < len(str); i += 1 {
+		if is_numeric(str[i]) {
+			continue
+		}
+
+		if str[i] == ':' {
+			got_more_time = true
+			break
+		}
+		
+		break
+	}
+
+	mins_str := str[start_idx:i]
+	if len(mins_str) != 2 {
+		return
+	}
+
+	mins := strconv.parse_int(mins_str, 10) or_return
+	if mins > 59 || mins < 0 {
+		return
+	}
+	ret_sec += i64(mins) * 60
+	if !got_more_time {
+		return ret_sec * sign, i, true
+	}
+
+	i += 1
+	start_idx = i
+
+	for ; i < len(str); i += 1 {
+		if !is_numeric(str[i]) {
+			break
+		}
+	}
+	secs_str := str[start_idx:i]
+	if len(secs_str) != 2 {
+		return
+	}
+
+	secs := strconv.parse_int(secs_str, 10) or_return
+	if secs > 59 || secs < 0 {
+		return
+	}
+	ret_sec += i64(secs)
+	return ret_sec * sign, i, true
+}
+
+@private
+skim_digits :: proc(str: string) -> (out: string, idx: int, ok: bool) {
+	i := 0
+	for ; i < len(str); i += 1 {
+		ch := str[i]
+		if ch == '.' || ch == '/' || ch == ',' {
+			break
+		}
+
+		if !is_numeric(ch) {
+			return
+		}
+	}
+
+	return str[:i], i, true
+}
+
+TWO_AM :: 2 * 60 * 60
+parse_posix_rrule :: proc(str: string) -> (out: datetime.TZ_Transition_Date, idx: int, ok: bool) {
+	str := str
+	if len(str) < 2 { return }
+
+	i := 0
+	// No leap
+	if str[i] == 'J' {
+		i += 1
+
+		day_str, off := skim_digits(str[i:]) or_return
+		i += off
+
+		day := strconv.parse_int(day_str, 10) or_return
+		if day < 1 || day > 365 { return }
+
+		offset : i64 = TWO_AM
+		if len(str) != i && str[i] == '/' {
+			i += 1
+
+			offset, off = parse_posix_tz_offset(str[i:]) or_return
+			i += off
+		}
+
+		if len(str) != i && str[i] == ',' {
+			i += 1
+		}
+
+		return datetime.TZ_Transition_Date{
+			type   = .NoLeap,
+			day    = u16(day),
+			time   = offset,
+		}, i, true
+
+	// Leap
+	} else if is_numeric(str[i]) {
+		day_str, off := skim_digits(str[i:]) or_return
+		i += off
+
+		day := strconv.parse_int(day_str, 10) or_return
+		if day < 0 || day > 365 { return }
+
+		offset : i64 = TWO_AM
+		if len(str) != i && str[i] == '/' {
+			i += 1
+
+			offset, off = parse_posix_tz_offset(str[i:]) or_return
+			i += off
+		}
+
+		if len(str) != i && str[i] == ',' {
+			i += 1
+		}
+
+		return datetime.TZ_Transition_Date{
+			type   = .Leap,
+			day    = u16(day),
+			time   = offset,
+		}, i, true
+
+	} else if str[i] == 'M' {
+		i += 1
+
+		month_str, week_str, day_str: string
+		off := 0
+
+		month_str, off = skim_digits(str[i:]) or_return
+		i += off + 1
+
+		week_str, off = skim_digits(str[i:]) or_return
+		i += off + 1
+
+		day_str, off = skim_digits(str[i:]) or_return
+		i += off
+
+		month := strconv.parse_int(month_str, 10) or_return
+		if month < 1 || month > 12 { return }
+
+		week := strconv.parse_int(week_str, 10) or_return
+		if week < 1 || week > 5 { return }
+
+		day := strconv.parse_int(day_str, 10) or_return
+		if day < 0 || day > 6 { return }
+
+		offset : i64 = TWO_AM
+		if len(str) != i && str[i] == '/' {
+			i += 1
+
+			offset, off = parse_posix_tz_offset(str[i:]) or_return
+			i += off
+		}
+
+		if len(str) != i && str[i] == ',' {
+			i += 1
+		}
+
+		return datetime.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = u8(month),
+			week   = u8(week),
+			day    = u16(day),
+			time = offset,
+		}, i, true
+	}
+
+	return
+}
+
+parse_posix_tz :: proc(posix_tz: string, allocator := context.allocator) -> (out: datetime.TZ_RRule, ok: bool) {
+	// TZ string contain at least 3 characters for the STD name, and 1 for the offset
+	if len(posix_tz) < 4 {
+		return
+	}
+
+	str := posix_tz
+
+	std_name, idx := parse_posix_tz_shortname(str) or_return
+	str = str[idx:]
+	
+	std_offset, idx2 := parse_posix_tz_offset(str) or_return
+	std_offset *= -1
+	str = str[idx2:]
+
+	std_name_str, err := strings.clone(std_name, allocator)
+	if err != nil { return }
+	defer if !ok { delete(std_name_str, allocator) }
+
+	if len(str) == 0 {
+		return datetime.TZ_RRule{
+			has_dst  = false,
+			std_name = std_name_str,
+			std_offset = std_offset,
+			std_date = datetime.TZ_Transition_Date{
+				type   = .Leap,
+				day    = 0,
+				time = TWO_AM,
+			},
+		}, true
+	}
+
+	dst_name: string
+	dst_offset := std_offset + (1 * 60 * 60)
+	if str[0] != ',' {
+		dst_name, idx = parse_posix_tz_shortname(str) or_return
+		str = str[idx:]
+
+		if str[0] != ',' {
+			dst_offset, idx = parse_posix_tz_offset(str) or_return
+			dst_offset *= -1
+			str = str[idx:]
+		}
+	}
+	if str[0] != ',' { return }
+	str = str[1:]
+
+	std_td, idx3 := parse_posix_rrule(str) or_return
+	str = str[idx3:]
+
+	dst_td, idx4 := parse_posix_rrule(str) or_return
+	str = str[idx4:]
+
+	dst_name_str: string
+	dst_name_str, err = strings.clone(dst_name, allocator)
+	if err != nil { return }
+
+	return datetime.TZ_RRule{
+		has_dst = true,
+
+		std_name   = std_name_str,
+		std_offset = std_offset,
+		std_date   = std_td,
+
+		dst_name   = dst_name_str,
+		dst_offset = dst_offset,
+		dst_date   = dst_td,
+	}, true
+}
+
+parse_tzif :: proc(_buffer: []u8, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) {
+	context.allocator = allocator
+
+	buffer := _buffer
+
+	// TZif is crufty. Skip the initial header.
+
+	v1_hdr := slice.to_type(buffer, TZif_Header) or_return
+	if v1_hdr.magic != TZIF_MAGIC {
+		return
+	}
+	if v1_hdr.typecnt == 0 || v1_hdr.charcnt == 0 {
+		return
+	}
+	if v1_hdr.isutcnt != 0 && v1_hdr.isutcnt != v1_hdr.typecnt {
+		return
+	}
+	if v1_hdr.isstdcnt != 0 && v1_hdr.isstdcnt != v1_hdr.typecnt {
+		return
+	}
+
+	// We don't bother supporting v1, it uses u32 timestamps
+	if v1_hdr.version == .V1 {
+		return
+	}
+	// We only support v2 and v3
+	if v1_hdr.version != .V2 && v1_hdr.version != .V3 {
+		return
+	}
+
+	// Skip the initial v1 block too.
+	first_block_size, _ := tzif_data_block_size(&v1_hdr, .V1)
+	if len(buffer) <= size_of(v1_hdr) + first_block_size {
+		return
+	}
+	buffer = buffer[size_of(v1_hdr)+first_block_size:]
+
+	// Ok, time to parse real things
+	real_hdr := slice.to_type(buffer, TZif_Header) or_return
+	if real_hdr.magic != TZIF_MAGIC {
+		return
+	}
+	if real_hdr.typecnt == 0 || real_hdr.charcnt == 0 {
+		return
+	}
+	if real_hdr.isutcnt != 0 && real_hdr.isutcnt != real_hdr.typecnt {
+		return
+	}
+	if real_hdr.isstdcnt != 0 && real_hdr.isstdcnt != real_hdr.typecnt {
+		return
+	}
+
+	// Grab the real data block
+	real_block_size, _ := tzif_data_block_size(&real_hdr, v1_hdr.version)
+	if len(buffer) <= size_of(real_hdr) + real_block_size {
+		return
+	}
+	buffer = buffer[size_of(real_hdr):]
+
+	time_size := 8
+	transition_times := slice.reinterpret([]i64be, buffer[:int(real_hdr.timecnt)*size_of(i64be)])
+	for time in transition_times {
+		if time < BIG_BANG_ISH {
+			return
+		}
+	}
+	buffer = buffer[int(real_hdr.timecnt)*time_size:]
+
+	transition_types := buffer[:int(real_hdr.timecnt)]
+	for type in transition_types {
+		if int(type) > int(real_hdr.typecnt - 1) {
+			return
+		}
+	}
+	buffer = buffer[int(real_hdr.timecnt):]
+
+	local_time_types := slice.reinterpret([]Local_Time_Type, buffer[:int(real_hdr.typecnt)*size_of(Local_Time_Type)])
+	for ltt in local_time_types {
+		// UT offset should be > -25 hours and < 26 hours
+		if int(ltt.utoff) < -89999 || int(ltt.utoff) > 93599 {
+			return
+		}
+
+		if ltt.dst != .DST && ltt.dst != .Standard {
+			return
+		}
+
+		if int(ltt.idx) > int(real_hdr.charcnt - 1) {
+			return
+		}
+	}
+
+	buffer = buffer[int(real_hdr.typecnt) * size_of(Local_Time_Type):]
+	timezone_string_table := buffer[:real_hdr.charcnt]
+	buffer = buffer[real_hdr.charcnt:]
+
+	leapsecond_records := slice.reinterpret([]Leapsecond_Record, buffer[:int(real_hdr.leapcnt)*size_of(Leapsecond_Record)])
+	if len(leapsecond_records) > 0 {
+		if leapsecond_records[0].occur < 0 {
+			return
+		}
+	}
+	buffer = buffer[(int(real_hdr.leapcnt) * size_of(Leapsecond_Record)):]
+
+	standard_wall_tags := buffer[:int(real_hdr.isstdcnt)]
+	buffer = buffer[int(real_hdr.isstdcnt):]
+
+	ut_tags := buffer[:int(real_hdr.isutcnt)]
+
+	for stdwall_tag, idx in standard_wall_tags {
+		ut_tag := ut_tags[idx]
+
+		if (stdwall_tag != 0 && stdwall_tag != 1) {
+			return
+		}
+		if (ut_tag != 0 && ut_tag != 1) {
+			return
+		}
+
+		if ut_tag == 1 && stdwall_tag != 1 {
+			return
+		}
+	}
+	buffer = buffer[int(real_hdr.isutcnt):]
+
+	// Start of footer
+	if buffer[0] != '\n' {
+		return
+	}
+	buffer = buffer[1:]
+
+	if buffer[0] == ':' {
+		return
+	}
+
+	end_idx := 0
+	for ch in buffer {
+		if ch == '\n' {
+			break
+		}
+
+		if ch == 0 {
+			return
+		}
+		end_idx += 1
+	}
+	footer_str := string(buffer[:end_idx])
+
+	// UTC is a special case, we don't need to alloc
+	if len(local_time_types) == 1 {
+		name := cstring(raw_data(timezone_string_table[local_time_types[0].idx:]))
+		if name != "UTC" {
+			return
+		}
+
+		return nil, true
+	}
+
+	ltt_names, err := make([dynamic]string, 0, len(local_time_types), allocator)
+	if err != nil { return }
+	defer if err != nil {
+		for name in ltt_names {
+			delete(name, allocator)
+		}
+		delete(ltt_names) 
+	}
+
+	for ltt in local_time_types {
+		name := cstring(raw_data(timezone_string_table[ltt.idx:]))
+		ltt_name: string
+
+		ltt_name, err = strings.clone_from_cstring_bounded(name, len(timezone_string_table), allocator)
+		if err != nil { return }
+
+		append(&ltt_names, ltt_name)
+	}
+
+	records: []datetime.TZ_Record
+	records, err = make([]datetime.TZ_Record, len(transition_times), allocator)
+	if err != nil { return }
+	defer if err != nil { delete(records, allocator) }
+
+	for trans_time, idx in transition_times {
+		trans_idx := transition_types[idx]
+		ltt := local_time_types[trans_idx]
+
+		records[idx] = datetime.TZ_Record{
+			time       = i64(trans_time),
+			utc_offset = i64(ltt.utoff),
+			shortname  = ltt_names[trans_idx],
+			dst        = bool(ltt.dst),
+		}
+	}
+
+	rrule, ok2 := parse_posix_tz(footer_str, allocator)
+	if !ok2 { return }
+	defer if err != nil {
+		delete(rrule.std_name, allocator)
+		delete(rrule.dst_name, allocator)
+	}
+
+	region_name_out: string
+	region_name_out, err = strings.clone(region_name, allocator)
+	if err != nil { return }
+	defer if err != nil { delete(region_name_out, allocator) }
+
+	region: ^datetime.TZ_Region
+	region, err = new_clone(datetime.TZ_Region{
+		records    = records,
+		shortnames = ltt_names[:],
+		name       = region_name_out,
+		rrule      = rrule,
+	}, allocator)
+	if err != nil {
+		return
+	}
+
+	return region, true
+}

+ 212 - 0
tests/core/time/test_core_time.odin

@@ -3,6 +3,7 @@ package test_core_time
 import "core:testing"
 import "core:time"
 import dt "core:time/datetime"
+import tz "core:time/timezone"
 
 is_leap_year :: time.is_leap_year
 
@@ -349,3 +350,214 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) {
 		moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss,
 	)
 }
+
+datetime_eq :: proc(dt1: dt.DateTime, dt2: dt.DateTime) -> bool {
+	return (
+		dt1.year == dt2.year && dt1.month == dt2.month   && dt1.day == dt2.day &&
+		dt1.hour == dt2.hour && dt1.minute == dt2.minute && dt1.second == dt2.second
+	)
+}
+
+@test
+test_convert_timezone_roundtrip :: proc(t: ^testing.T) {
+	dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0)
+	std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0)
+
+	local_tz, local_load_ok := tz.region_load("local")
+	testing.expectf(t, local_load_ok, "Failed to load local timezone")
+	defer tz.region_destroy(local_tz)
+
+	edm_tz, edm_load_ok := tz.region_load("America/Edmonton")
+	testing.expectf(t, edm_load_ok, "Failed to load America/Edmonton timezone")
+	defer tz.region_destroy(edm_tz)
+
+	shuffle_tz :: proc(start_dt: dt.DateTime, test_tz: ^dt.TZ_Region) -> dt.DateTime {
+		tz_dt := tz.datetime_to_tz(start_dt, test_tz)
+		utc_dt := tz.datetime_to_utc(tz_dt)
+		return utc_dt
+	}
+
+	testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, local_tz)), "Failed to convert to/from local dst timezone")
+	testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, local_tz)), "Failed to convert to/from local std timezone")
+	testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, edm_tz)), "Failed to convert to/from Edmonton dst timezone")
+	testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, edm_tz)), "Failed to convert to/from Edmonton std timezone")
+}
+
+@test
+test_check_timezone_metadata :: proc(t: ^testing.T) {
+	dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0)
+	std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0)
+
+	pac_tz, pac_load_ok := tz.region_load("America/Los_Angeles")
+	testing.expectf(t, pac_load_ok, "Failed to load America/Los_Angeles timezone")
+	defer tz.region_destroy(pac_tz)
+
+	pac_dst_dt := tz.datetime_to_tz(dst_dt, pac_tz)
+	pac_std_dt := tz.datetime_to_tz(std_dt, pac_tz)
+	testing.expectf(t, tz.shortname_unsafe(pac_dst_dt) == "PDT", "Invalid timezone shortname")
+	testing.expectf(t, tz.shortname_unsafe(pac_std_dt) == "PST", "Invalid timezone shortname")
+	testing.expectf(t, tz.dst_unsafe(pac_std_dt) == false, "Expected daylight savings == false, got true")
+	testing.expectf(t, tz.dst_unsafe(pac_dst_dt) == true, "Expected daylight savings == true, got false")
+
+	pac_dst_name, ok := tz.shortname(pac_dst_dt)
+	testing.expectf(t, ok == true, "Invalid datetime")
+	testing.expectf(t, pac_dst_name == "PDT", "Invalid timezone shortname")
+
+	pac_std_name, ok2 := tz.shortname(pac_std_dt)
+	testing.expectf(t, ok2 == true, "Invalid datetime")
+	testing.expectf(t, pac_std_name == "PST", "Invalid timezone shortname")
+
+	pac_is_dst, ok3 := tz.dst(pac_dst_dt)
+	testing.expectf(t, ok3 == true, "Invalid datetime")
+	testing.expectf(t, pac_is_dst == true, "Expected daylight savings == false, got true")
+
+	pac_is_dst, ok3 = tz.dst(pac_std_dt)
+	testing.expectf(t, ok3 == true, "Invalid datetime")
+	testing.expectf(t, pac_is_dst == false, "Expected daylight savings == false, got true")
+}
+
+rrule_eq :: proc(r1, r2: dt.TZ_RRule) -> (eq: bool) {
+	if r1.has_dst    != r2.has_dst { return }
+
+	if r1.std_name   != r2.std_name { return }
+	if r1.std_offset != r2.std_offset { return }
+	if r1.std_date   != r2.std_date { return }
+
+	if r1.dst_name   != r2.dst_name { return }
+	if r1.dst_offset != r2.dst_offset { return }
+	if r1.dst_date   != r2.dst_date { return }
+
+	return true
+}
+
+@test
+test_check_timezone_posix_tz :: proc(t: ^testing.T) {
+	correct_simple_rrule := dt.TZ_RRule{
+		has_dst    = false,
+
+		std_name   = "UTC",
+		std_offset = -(5 * 60 * 60),
+		std_date   = dt.TZ_Transition_Date{
+			type   = .Leap,
+			day    = 0,
+			time   = 2 * 60 * 60,
+		},
+	}
+
+	simple_rrule, simple_rrule_ok := tz.parse_posix_tz("UTC+5")
+	testing.expectf(t, simple_rrule_ok, "Failed to parse posix tz")
+	defer tz.rrule_destroy(simple_rrule)
+	testing.expectf(t, rrule_eq(simple_rrule, correct_simple_rrule), "POSIX TZ parsed incorrectly")
+
+	correct_est_rrule := dt.TZ_RRule{
+		has_dst    = true,
+
+		std_name   = "EST",
+		std_offset = -(5 * 60 * 60),
+		std_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 3,
+			week   = 2,
+			day    = 0,
+			time   = 2 * 60 * 60,
+		},
+
+		dst_name   = "EDT",
+		dst_offset = -(4 * 60 * 60),
+		dst_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 11,
+			week   = 1,
+			day    = 0,
+			time   = 2 * 60 * 60,
+		},
+	}
+
+	est_rrule, est_rrule_ok := tz.parse_posix_tz("EST+5EDT,M3.2.0/2,M11.1.0/2")
+	testing.expectf(t, est_rrule_ok, "Failed to parse posix tz")
+	defer tz.rrule_destroy(est_rrule)
+	testing.expectf(t, rrule_eq(est_rrule, correct_est_rrule), "POSIX TZ parsed incorrectly")
+
+	correct_ist_rrule := dt.TZ_RRule{
+		has_dst    = true,
+
+		std_name   = "IST",
+		std_offset = (2 * 60 * 60),
+		std_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 3,
+			week   = 4,
+			day    = 4,
+			time   = 26 * 60 * 60,
+		},
+
+		dst_name   = "IDT",
+		dst_offset = (3 * 60 * 60),
+		dst_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 10,
+			week   = 5,
+			day    = 0,
+			time   = 2 * 60 * 60,
+		},
+	}
+
+	ist_rrule, ist_rrule_ok := tz.parse_posix_tz("IST-2IDT,M3.4.4/26,M10.5.0")
+	testing.expectf(t, ist_rrule_ok, "Failed to parse posix tz")
+	defer tz.rrule_destroy(ist_rrule)
+	testing.expectf(t, rrule_eq(ist_rrule, correct_ist_rrule), "POSIX TZ parsed incorrectly")
+
+	correct_warst_rrule := dt.TZ_RRule{
+		has_dst    = true,
+
+		std_name   = "WART",
+		std_offset = -(4 * 60 * 60),
+		std_date   = dt.TZ_Transition_Date{
+			type   = .NoLeap,
+			day    = 1,
+			time   = 0 * 60 * 60,
+		},
+
+		dst_name   = "WARST",
+		dst_offset = -(3 * 60 * 60),
+		dst_date   = dt.TZ_Transition_Date{
+			type   = .NoLeap,
+			day    = 365,
+			time   = 25 * 60 * 60,
+		},
+	}
+
+	warst_rrule, warst_rrule_ok := tz.parse_posix_tz("WART4WARST,J1/0,J365/25")
+	testing.expectf(t, warst_rrule_ok, "Failed to parse posix tz")
+	defer tz.rrule_destroy(warst_rrule)
+	testing.expectf(t, rrule_eq(warst_rrule, correct_warst_rrule), "POSIX TZ parsed incorrectly")
+
+	correct_wgt_rrule := dt.TZ_RRule{
+		has_dst    = true,
+
+		std_name   = "WGT",
+		std_offset = -(3 * 60 * 60),
+		std_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 3,
+			week   = 5,
+			day    = 0,
+			time   = -2 * 60 * 60,
+		},
+
+		dst_name   = "WGST",
+		dst_offset = -(2 * 60 * 60),
+		dst_date   = dt.TZ_Transition_Date{
+			type   = .MonthWeekDay,
+			month  = 10,
+			week   = 5,
+			day    = 0,
+			time   = -1 * 60 * 60,
+		},
+	}
+
+	wgt_rrule, wgt_rrule_ok := tz.parse_posix_tz("WGT3WGST,M3.5.0/-2,M10.5.0/-1")
+	testing.expectf(t, wgt_rrule_ok, "Failed to parse posix tz")
+	defer tz.rrule_destroy(wgt_rrule)
+	testing.expectf(t, rrule_eq(wgt_rrule, correct_wgt_rrule), "POSIX TZ parsed incorrectly")
+}